From 2a9f90c01d516619d48b9d520cfa5630183cf380 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 24 Mar 2026 08:13:26 +0100 Subject: [PATCH] refactor: migrate read_status from JSON file to SQLite database Replace file-based .read_status.json with DB-backed read_status table. One-time migration imports existing data at startup. The read_status.py module keeps the same public API so route handlers need no changes. Co-Authored-By: Claude Opus 4.6 --- app/database.py | 8 ++ app/main.py | 21 ++++ app/read_status.py | 257 +++++++++++---------------------------------- 3 files changed, 91 insertions(+), 195 deletions(-) diff --git a/app/database.py b/app/database.py index 6175934..df6c543 100644 --- a/app/database.py +++ b/app/database.py @@ -969,6 +969,14 @@ class Database: (key, 1 if muted else 0) ) + def get_muted_channels(self) -> List[int]: + """Get list of muted channel indices.""" + with self._connect() as conn: + rows = conn.execute( + "SELECT key FROM read_status WHERE is_muted = 1 AND key LIKE 'chan_%'" + ).fetchall() + return [int(r['key'][5:]) for r in rows] + # ================================================================ # Full-Text Search # ================================================================ diff --git a/app/main.py b/app/main.py index c78f88e..0dfef8b 100644 --- a/app/main.py +++ b/app/main.py @@ -244,6 +244,27 @@ def create_app(): except Exception as e: logger.warning(f"Could not rename settings file: {e}") + # Migrate .read_status.json to DB (one-time) + read_status_file = Path(config.MC_CONFIG_DIR) / '.read_status.json' + if read_status_file.exists(): + try: + import json as _json + with open(read_status_file, 'r', encoding='utf-8') as f: + rs_data = _json.load(f) + migrated = 0 + for ch_idx, ts in rs_data.get('channels', {}).items(): + db.mark_read(f"chan_{ch_idx}", int(ts)) + migrated += 1 + for conv_id, ts in rs_data.get('dm', {}).items(): + db.mark_read(f"dm_{conv_id}", int(ts)) + migrated += 1 + for ch_idx in rs_data.get('muted_channels', []): + db.set_channel_muted(int(ch_idx), True) + read_status_file.rename(read_status_file.with_suffix('.json.bak')) + logger.info(f"Migrated {migrated} read status entries to DB") + except Exception as e: + logger.warning(f"Failed to migrate .read_status.json: {e}") + # v2: Initialize and start device manager device_manager = DeviceManager(config, db, socketio) app.device_manager = device_manager diff --git a/app/read_status.py b/app/read_status.py index 0565581..a4a2f2a 100644 --- a/app/read_status.py +++ b/app/read_status.py @@ -1,198 +1,101 @@ """ -Read Status Manager - Server-side storage for message read status +Read Status Manager - DB-backed storage for message read status Manages the last seen timestamps for channels and DM conversations, providing cross-device synchronization for unread message tracking. +All data is stored in the read_status table of the SQLite database. """ -import json import logging -import os -from pathlib import Path -from threading import Lock -from app.config import config logger = logging.getLogger(__name__) -# Thread-safe lock for file operations -_status_lock = Lock() -# Path to read status file -READ_STATUS_FILE = Path(config.MC_CONFIG_DIR) / '.read_status.json' - - -def _get_default_status(): - """Get default read status structure""" - return { - 'channels': {}, # {"0": timestamp, "1": timestamp, ...} - 'dm': {}, # {"name_User1": timestamp, "pk_abc123": timestamp, ...} - 'muted_channels': [] # [2, 5, 7] - channel indices with muted notifications - } +def _get_db(): + """Get database instance (deferred import to avoid circular imports).""" + from app.main import db + return db def load_read_status(): - """ - Load read status from disk. + """Load read status from database. Returns: - dict: Read status with 'channels' and 'dm' keys + dict: Read status with 'channels', 'dm', and 'muted_channels' keys """ - with _status_lock: - try: - if not READ_STATUS_FILE.exists(): - logger.info("Read status file does not exist, creating default") - return _get_default_status() + try: + db = _get_db() + rows = db.get_read_status() - with open(READ_STATUS_FILE, 'r', encoding='utf-8') as f: - status = json.load(f) + channels = {} + dm = {} + muted_channels = [] - # Validate structure - if not isinstance(status, dict): - logger.warning("Invalid read status structure, resetting") - return _get_default_status() + for key, row in rows.items(): + if key.startswith('chan_'): + chan_idx = key[5:] # "chan_0" -> "0" + channels[chan_idx] = row['last_seen_ts'] + if row.get('is_muted'): + try: + muted_channels.append(int(chan_idx)) + except ValueError: + pass + elif key.startswith('dm_'): + conv_id = key[3:] # "dm_name_User1" -> "name_User1" + dm[conv_id] = row['last_seen_ts'] - # Ensure all keys exist - if 'channels' not in status: - status['channels'] = {} - if 'dm' not in status: - status['dm'] = {} - if 'muted_channels' not in status: - status['muted_channels'] = [] + return { + 'channels': channels, + 'dm': dm, + 'muted_channels': muted_channels, + } - logger.debug(f"Loaded read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations") - return status - - except json.JSONDecodeError as e: - logger.error(f"Failed to parse read status file: {e}") - return _get_default_status() - except Exception as e: - logger.error(f"Error loading read status: {e}") - return _get_default_status() + except Exception as e: + logger.error(f"Error loading read status: {e}") + return {'channels': {}, 'dm': {}, 'muted_channels': []} def save_read_status(status): - """ - Save read status to disk. - - Args: - status (dict): Read status with 'channels' and 'dm' keys - - Returns: - bool: True if successful, False otherwise - """ - with _status_lock: - try: - # Ensure directory exists - READ_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True) - - # Write atomically (write to temp file, then rename) - temp_file = READ_STATUS_FILE.with_suffix('.tmp') - with open(temp_file, 'w', encoding='utf-8') as f: - json.dump(status, f, indent=2) - - # Atomic rename - temp_file.replace(READ_STATUS_FILE) - - logger.debug(f"Saved read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations") - return True - - except Exception as e: - logger.error(f"Error saving read status: {e}") - return False + """No-op — data is written per-operation via mark_* functions.""" + return True def mark_channel_read(channel_idx, timestamp): - """ - Mark a channel as read up to a specific timestamp. - - Args: - channel_idx (int or str): Channel index (will be converted to string) - timestamp (int or float): Unix timestamp of last read message - - Returns: - bool: True if successful, False otherwise - """ + """Mark a channel as read up to a specific timestamp.""" try: - # Load current status - status = load_read_status() - - # Update channel timestamp (ensure key is string for JSON compatibility) - channel_key = str(channel_idx) - status['channels'][channel_key] = int(timestamp) - - # Save updated status - success = save_read_status(status) - - if success: - logger.debug(f"Marked channel {channel_idx} as read at timestamp {timestamp}") - - return success - + db = _get_db() + db.mark_read(f"chan_{channel_idx}", int(timestamp)) + logger.debug(f"Marked channel {channel_idx} as read at timestamp {timestamp}") + return True except Exception as e: logger.error(f"Error marking channel {channel_idx} as read: {e}") return False def mark_dm_read(conversation_id, timestamp): - """ - Mark a DM conversation as read up to a specific timestamp. - - Args: - conversation_id (str): Conversation identifier (e.g., "name_User1" or "pk_abc123") - timestamp (int or float): Unix timestamp of last read message - - Returns: - bool: True if successful, False otherwise - """ + """Mark a DM conversation as read up to a specific timestamp.""" try: - # Load current status - status = load_read_status() - - # Update DM timestamp - status['dm'][conversation_id] = int(timestamp) - - # Save updated status - success = save_read_status(status) - - if success: - logger.debug(f"Marked DM conversation {conversation_id} as read at timestamp {timestamp}") - - return success - + db = _get_db() + db.mark_read(f"dm_{conversation_id}", int(timestamp)) + logger.debug(f"Marked DM conversation {conversation_id} as read at timestamp {timestamp}") + return True except Exception as e: logger.error(f"Error marking DM conversation {conversation_id} as read: {e}") return False def get_channel_last_seen(channel_idx): - """ - Get last seen timestamp for a specific channel. - - Args: - channel_idx (int or str): Channel index - - Returns: - int: Unix timestamp, or 0 if never seen - """ + """Get last seen timestamp for a specific channel.""" try: status = load_read_status() - channel_key = str(channel_idx) - return status['channels'].get(channel_key, 0) + return status['channels'].get(str(channel_idx), 0) except Exception as e: logger.error(f"Error getting last seen for channel {channel_idx}: {e}") return 0 def get_dm_last_seen(conversation_id): - """ - Get last seen timestamp for a specific DM conversation. - - Args: - conversation_id (str): Conversation identifier - - Returns: - int: Unix timestamp, or 0 if never seen - """ + """Get last seen timestamp for a specific DM conversation.""" try: status = load_read_status() return status['dm'].get(conversation_id, 0) @@ -202,75 +105,39 @@ def get_dm_last_seen(conversation_id): def get_muted_channels(): - """ - Get list of muted channel indices. - - Returns: - list: List of muted channel indices (integers) - """ + """Get list of muted channel indices.""" try: - status = load_read_status() - return status.get('muted_channels', []) + db = _get_db() + return db.get_muted_channels() except Exception as e: logger.error(f"Error getting muted channels: {e}") return [] def set_channel_muted(channel_idx, muted): - """ - Set mute state for a channel. - - Args: - channel_idx (int): Channel index - muted (bool): True to mute, False to unmute - - Returns: - bool: True if successful - """ + """Set mute state for a channel.""" try: - status = load_read_status() - muted_list = status.get('muted_channels', []) - channel_idx = int(channel_idx) - - if muted and channel_idx not in muted_list: - muted_list.append(channel_idx) - elif not muted and channel_idx in muted_list: - muted_list.remove(channel_idx) - - status['muted_channels'] = muted_list - success = save_read_status(status) - - if success: - logger.info(f"Channel {channel_idx} {'muted' if muted else 'unmuted'}") - return success - + db = _get_db() + db.set_channel_muted(int(channel_idx), muted) + logger.info(f"Channel {channel_idx} {'muted' if muted else 'unmuted'}") + return True except Exception as e: logger.error(f"Error setting mute for channel {channel_idx}: {e}") return False def mark_all_channels_read(channel_timestamps): - """ - Mark all channels as read in bulk. + """Mark all channels as read in bulk. Args: channel_timestamps (dict): {"0": timestamp, "1": timestamp, ...} - - Returns: - bool: True if successful """ try: - status = load_read_status() - + db = _get_db() for channel_key, timestamp in channel_timestamps.items(): - status['channels'][str(channel_key)] = int(timestamp) - - success = save_read_status(status) - - if success: - logger.info(f"Marked {len(channel_timestamps)} channels as read") - return success - + db.mark_read(f"chan_{channel_key}", int(timestamp)) + logger.info(f"Marked {len(channel_timestamps)} channels as read") + return True except Exception as e: logger.error(f"Error marking all channels as read: {e}") return False