Merge pull request #167 from jkingsman/migrate-favorites

Add favorites as contact field (dug)
This commit is contained in:
Jack Kingsman
2026-04-05 22:19:01 -07:00
committed by GitHub
55 changed files with 482 additions and 466 deletions
+4 -2
View File
@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS contacts (
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER,
last_read_at INTEGER
last_read_at INTEGER,
favorite INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS channels (
@@ -37,7 +38,8 @@ CREATE TABLE IF NOT EXISTS channels (
on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT,
path_hash_mode_override INTEGER,
last_read_at INTEGER
last_read_at INTEGER,
favorite INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
+94
View File
@@ -413,6 +413,12 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 54)
applied += 1
if version < 55:
logger.info("Applying migration 55: move favorites to per-entity columns")
await _migrate_055_favorites_to_columns(conn)
await set_version(conn, 55)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -3213,3 +3219,91 @@ async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
"ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0"
)
await conn.commit()
async def _migrate_055_favorites_to_columns(conn: aiosqlite.Connection) -> None:
"""Move favorites from app_settings JSON blob to per-entity boolean columns.
1. Add ``favorite`` column to contacts and channels tables.
2. Backfill from the ``app_settings.favorites`` JSON array.
3. Drop the ``favorites`` column from app_settings.
"""
import json as _json
# --- Add columns ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
for table in ("contacts", "channels"):
if table not in existing_tables:
continue
col_cursor = await conn.execute(f"PRAGMA table_info({table})")
columns = {row[1] for row in await col_cursor.fetchall()}
if "favorite" not in columns:
await conn.execute(f"ALTER TABLE {table} ADD COLUMN favorite INTEGER DEFAULT 0")
await conn.commit()
# --- Backfill from JSON ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
settings_columns = {row[1] for row in await col_cursor.fetchall()}
if "favorites" not in settings_columns:
await conn.commit()
return
cursor = await conn.execute("SELECT favorites FROM app_settings WHERE id = 1")
row = await cursor.fetchone()
if row and row[0]:
try:
favorites = _json.loads(row[0])
except (ValueError, TypeError):
favorites = []
contact_keys = []
channel_keys = []
for fav in favorites:
if not isinstance(fav, dict):
continue
fav_type = fav.get("type")
fav_id = fav.get("id")
if not fav_id:
continue
if fav_type == "contact":
contact_keys.append(fav_id)
elif fav_type == "channel":
channel_keys.append(fav_id)
if contact_keys:
placeholders = ",".join("?" for _ in contact_keys)
await conn.execute(
f"UPDATE contacts SET favorite = 1 WHERE public_key IN ({placeholders})",
contact_keys,
)
if channel_keys:
placeholders = ",".join("?" for _ in channel_keys)
await conn.execute(
f"UPDATE channels SET favorite = 1 WHERE key IN ({placeholders})",
channel_keys,
)
if contact_keys or channel_keys:
logger.info(
"Backfilled %d contact favorite(s) and %d channel favorite(s) from app_settings",
len(contact_keys),
len(channel_keys),
)
await conn.commit()
# --- Drop the JSON column ---
try:
await conn.execute("ALTER TABLE app_settings DROP COLUMN favorites")
await conn.commit()
except Exception as e:
error_msg = str(e).lower()
if "syntax error" in error_msg or "drop column" in error_msg:
logger.debug("SQLite doesn't support DROP COLUMN; favorites column will remain unused")
await conn.commit()
else:
raise
+2 -10
View File
@@ -91,6 +91,7 @@ class Contact(BaseModel):
lon: float | None = None
last_seen: int | None = None
on_radio: bool = False
favorite: bool = False
last_contacted: int | None = None # Last time we sent/received a message
last_read_at: int | None = None # Server-side read state tracking
first_seen: int | None = None
@@ -326,6 +327,7 @@ class Channel(BaseModel):
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
)
last_read_at: int | None = None # Server-side read state tracking
favorite: bool = False
class ChannelMessageCounts(BaseModel):
@@ -756,13 +758,6 @@ class RadioDiscoveryResponse(BaseModel):
)
class Favorite(BaseModel):
"""A favorite conversation."""
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
id: str = Field(description="Channel key or contact public key")
class UnreadCounts(BaseModel):
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
@@ -790,9 +785,6 @@ class AppSettings(BaseModel):
"favorites reload first, then background fill targets about 80% of this value"
),
)
favorites: list[Favorite] = Field(
default_factory=list, description="List of favorited conversations"
)
auto_decrypt_dm_on_advert: bool = Field(
default=True,
description="Whether to attempt historical DM decryption on new contact advertisement",
+12 -30
View File
@@ -21,7 +21,7 @@ from meshcore import EventType, MeshCore
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
from app.config import settings
from app.event_handlers import cleanup_expired_acks, on_contact_message
from app.models import Contact, ContactUpsert, Favorite
from app.models import Contact, ContactUpsert
from app.radio import RadioOperationBusyError
from app.repository import (
AmbiguousPublicKeyPrefixError,
@@ -1071,18 +1071,17 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
logger.debug("Synced %d contacts from radio snapshot", synced)
# Import radio-favorited contacts into app favorites
radio_fav_keys = {pk for pk, data in contacts.items() if data.get("flags", 0) & 0x01}
radio_fav_keys = [pk for pk, data in contacts.items() if data.get("flags", 0) & 0x01]
if radio_fav_keys:
try:
settings_obj = await AppSettingsRepository.get()
existing_fav_ids = {f.id for f in settings_obj.favorites}
new_favs = radio_fav_keys - existing_fav_ids
if new_favs:
merged = settings_obj.favorites + [
Favorite(type="contact", id=pk) for pk in sorted(new_favs)
]
await AppSettingsRepository.update(favorites=merged)
logger.info("Imported %d radio favorite(s) into app favorites", len(new_favs))
imported = 0
for pk in radio_fav_keys:
existing = await ContactRepository.get_by_key(pk)
if existing and not existing.favorite:
await ContactRepository.set_favorite(pk, True)
imported += 1
if imported:
logger.info("Imported %d radio favorite(s) into app favorites", imported)
except Exception as e:
logger.warning("Failed to import radio favorites: %s", e)
@@ -1297,26 +1296,9 @@ async def get_contacts_selected_for_radio_sync() -> list[Contact]:
selected_contacts: list[Contact] = []
selected_keys: set[str] = set()
# Favorites first — always loaded up to max_contacts
favorite_contacts_loaded = 0
for favorite in app_settings.favorites:
if favorite.type != "contact":
continue
try:
contact = await ContactRepository.get_by_key_or_prefix(favorite.id)
except AmbiguousPublicKeyPrefixError:
logger.warning(
"Skipping favorite contact '%s': ambiguous key prefix; use full key",
favorite.id,
)
continue
if not contact:
continue
if len(contact.public_key) < 64:
logger.debug(
"Skipping unresolved prefix-only favorite contact '%s' for radio sync",
favorite.id,
)
continue
for contact in await ContactRepository.get_favorites():
key = contact.public_key.lower()
if key in selected_keys:
continue
+14 -2
View File
@@ -26,7 +26,7 @@ class ChannelRepository:
"""Get a channel by its key (32-char hex string)."""
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
FROM channels
WHERE key = ?
""",
@@ -42,6 +42,7 @@ class ChannelRepository:
flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
favorite=bool(row["favorite"]),
)
return None
@@ -49,7 +50,7 @@ class ChannelRepository:
async def get_all() -> list[Channel]:
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
FROM channels
ORDER BY name
"""
@@ -64,10 +65,21 @@ class ChannelRepository:
flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
favorite=bool(row["favorite"]),
)
for row in rows
]
@staticmethod
async def set_favorite(key: str, value: bool) -> bool:
"""Set or clear the favorite flag for a channel. Returns True if row was found."""
cursor = await db.conn.execute(
"UPDATE channels SET favorite = ? WHERE key = ?",
(1 if value else 0, key.upper()),
)
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def delete(key: str) -> None:
"""Delete a channel by key."""
+19
View File
@@ -170,6 +170,7 @@ class ContactRepository:
lon=row["lon"],
last_seen=row["last_seen"],
on_radio=bool(row["on_radio"]),
favorite=bool(row["favorite"]) if "favorite" in available_columns else False,
last_contacted=row["last_contacted"],
last_read_at=row["last_read_at"],
first_seen=row["first_seen"],
@@ -392,6 +393,24 @@ class ContactRepository:
)
await db.conn.commit()
@staticmethod
async def get_favorites() -> list[Contact]:
"""Return all contacts marked as favorite."""
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE favorite = 1 AND LENGTH(public_key) = 64"
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def set_favorite(public_key: str, value: bool) -> None:
"""Set or clear the favorite flag for a contact."""
await db.conn.execute(
"UPDATE contacts SET favorite = ? WHERE public_key = ?",
(1 if value else 0, public_key.lower()),
)
await db.conn.commit()
@staticmethod
async def delete(public_key: str) -> None:
normalized = public_key.lower()
+3 -45
View File
@@ -1,10 +1,10 @@
import json
import logging
import time
from typing import Any, Literal
from typing import Any
from app.database import db
from app.models import AppSettings, Favorite
from app.models import AppSettings
from app.path_utils import bucket_path_hash_widths
logger = logging.getLogger(__name__)
@@ -26,7 +26,7 @@ class AppSettingsRepository:
"""
cursor = await db.conn.execute(
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
SELECT max_radio_contacts, auto_decrypt_dm_on_advert,
last_message_times,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types,
@@ -40,20 +40,6 @@ class AppSettingsRepository:
# Should not happen after migration, but handle gracefully
return AppSettings()
# Parse favorites JSON
favorites = []
if row["favorites"]:
try:
favorites_data = json.loads(row["favorites"])
favorites = [Favorite(**f) for f in favorites_data]
except (json.JSONDecodeError, TypeError, KeyError) as e:
logger.warning(
"Failed to parse favorites JSON, using empty list: %s (data=%r)",
e,
row["favorites"][:100] if row["favorites"] else None,
)
favorites = []
# Parse last_message_times JSON
last_message_times: dict[str, int] = {}
if row["last_message_times"]:
@@ -107,7 +93,6 @@ class AppSettingsRepository:
return AppSettings(
max_radio_contacts=row["max_radio_contacts"],
favorites=favorites,
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
last_message_times=last_message_times,
advert_interval=row["advert_interval"] or 0,
@@ -123,7 +108,6 @@ class AppSettingsRepository:
@staticmethod
async def update(
max_radio_contacts: int | None = None,
favorites: list[Favorite] | None = None,
auto_decrypt_dm_on_advert: bool | None = None,
last_message_times: dict[str, int] | None = None,
advert_interval: int | None = None,
@@ -143,11 +127,6 @@ class AppSettingsRepository:
updates.append("max_radio_contacts = ?")
params.append(max_radio_contacts)
if favorites is not None:
updates.append("favorites = ?")
favorites_json = json.dumps([f.model_dump() for f in favorites])
params.append(favorites_json)
if auto_decrypt_dm_on_advert is not None:
updates.append("auto_decrypt_dm_on_advert = ?")
params.append(1 if auto_decrypt_dm_on_advert else 0)
@@ -195,27 +174,6 @@ class AppSettingsRepository:
return await AppSettingsRepository.get()
@staticmethod
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
"""Add a favorite, avoiding duplicates."""
settings = await AppSettingsRepository.get()
# Check if already favorited
if any(f.type == fav_type and f.id == fav_id for f in settings.favorites):
return settings
new_favorites = settings.favorites + [Favorite(type=fav_type, id=fav_id)]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
"""Remove a favorite."""
settings = await AppSettingsRepository.get()
new_favorites = [
f for f in settings.favorites if not (f.type == fav_type and f.id == fav_id)
]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod
async def toggle_blocked_key(key: str) -> AppSettings:
"""Toggle a public key in the blocked list. Keys are normalized to lowercase."""
+27 -18
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field
from app.models import CONTACT_TYPE_REPEATER, AppSettings
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository, ContactRepository
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -72,6 +72,12 @@ class FavoriteRequest(BaseModel):
id: str = Field(description="Channel key or contact public key")
class FavoriteToggleResponse(BaseModel):
type: Literal["channel", "contact"]
id: str
favorite: bool
class TrackedTelemetryRequest(BaseModel):
public_key: str = Field(description="Public key of the repeater to toggle tracking")
@@ -157,27 +163,30 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
return await AppSettingsRepository.get()
@router.post("/favorites/toggle", response_model=AppSettings)
async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
@router.post("/favorites/toggle", response_model=FavoriteToggleResponse)
async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse:
"""Toggle a conversation's favorite status."""
settings = await AppSettingsRepository.get()
is_favorited = any(f.type == request.type and f.id == request.id for f in settings.favorites)
if request.type == "contact":
contact = await ContactRepository.get_by_key(request.id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
new_value = not contact.favorite
await ContactRepository.set_favorite(request.id, new_value)
logger.info("%s contact favorite: %s", "Added" if new_value else "Removed", request.id[:12])
# When newly favorited, load to radio immediately for DM ACK support
if new_value:
from app.radio_sync import ensure_contact_on_radio
if is_favorited:
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
result = await AppSettingsRepository.remove_favorite(request.type, request.id)
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
else:
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
result = await AppSettingsRepository.add_favorite(request.type, request.id)
channel = await ChannelRepository.get_by_key(request.id)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
new_value = not channel.favorite
await ChannelRepository.set_favorite(request.id, new_value)
logger.info("%s channel favorite: %s", "Added" if new_value else "Removed", request.id[:12])
# When a contact is newly favorited, load just that contact to the radio
# immediately so DM ACK support does not wait for the next maintenance cycle.
if request.type == "contact" and not is_favorited:
from app.radio_sync import ensure_contact_on_radio
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
return result
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
@router.post("/blocked-keys/toggle", response_model=AppSettings)