# Cache only derived read-only data. A DB replacement or WAL commit invalidates # its entry; no SQLite connection or file handle remains open between requests. _CACHE_LOCK = threading.RLock() _CACHES: dict[str, OrderedDict] = {} _CACHE_LIMITS = {"index": 4, "names": 16, "files": 16, "messages": 32} def clear_browser_cache() -> None: with _CACHE_LOCK: _CACHES.clear() def _database_revision(path: Path) -> tuple: values = [str(path.resolve()).casefold()] for candidate in (path, Path(str(path) + "-wal")): try: info = candidate.stat() values.append((info.st_size, info.st_mtime_ns, info.st_ctime_ns, info.st_ino)) except OSError: values.append(None) return tuple(values) def _cached(kind: str, key: Any, revision: tuple, build): # Building under the lock coalesces simultaneous navigation requests. The # GUI already loads in a worker; repeated searches cannot multiply scans. with _CACHE_LOCK: cache = _CACHES.setdefault(kind, OrderedDict()) found = cache.get(key) if found is not None and found[0] == revision: cache.move_to_end(key) return found[1] value = build() cache[key] = (revision, value) cache.move_to_end(key) while len(cache) > _CACHE_LIMITS.get(kind, 16): cache.popitem(last=False) return value def _message_index(message_path: Path) -> dict: revision = _database_revision(message_path) def build(): connection = connect_sqlite(str(message_path)) try: columns = _columns(connection, "message_table") result = {"columns": columns, "total_messages": 0, "conversations": {}, "ordered": [], "previews": {}, "revision": revision} if not {"conversation_id", "send_time"}.issubset(columns): return result groups: dict[str, list] = {} cursor = connection.execute("SELECT rowid,conversation_id,send_time FROM message_table") for row_id, conversation_id, timestamp in cursor: result["total_messages"] += 1 conversation_id = str(conversation_id or "") if not conversation_id or conversation_id.startswith("Y:"): continue try: timestamp = float(timestamp or 0) if not math.isfinite(timestamp): timestamp = 0.0 except (ValueError, TypeError, OverflowError): timestamp = 0.0 groups.setdefault(conversation_id, []).append((timestamp, row_id)) for conversation_id, rows in groups.items(): rows.sort() result["conversations"][conversation_id] = { "rows": array("q", (row_id for _timestamp, row_id in rows)), "lastTimestamp": rows[-1][0], "lastRowId": rows[-1][1], } result["ordered"] = sorted(result["conversations"], key=lambda key: ( result["conversations"][key]["lastTimestamp"], result["conversations"][key]["lastRowId"], key), reverse=True) return result finally: connection.close() return _cached("index", revision[0], revision, build) def _read_message_rows(message_path: Path, index: dict, row_ids: list[int]) -> dict[int, dict]: if not row_ids: return {} columns = index["columns"] wanted = [name for name in ("sender_id", "send_time", "content_type", "content", "server_id", "client_id") if name in columns] if not wanted: return {} connection = connect_sqlite(str(message_path)) try: result = {} for offset in range(0, len(row_ids), 500): batch = row_ids[offset:offset + 500] marks = ",".join("?" for _ in batch) for row in connection.execute(f"SELECT rowid,{','.join(wanted)} FROM message_table WHERE rowid IN ({marks})", batch): result[int(row[0])] = dict(zip(wanted, row[1:])) return result finally: connection.close() def _ensure_previews(message_path: Path, index: dict, conversation_ids: list[str]) -> None: with _CACHE_LOCK: missing = [key for key in conversation_ids if key not in index["previews"]] rows = _read_message_rows(message_path, index, [index["conversations"][key]["lastRowId"] for key in missing]) for key in missing: item = rows.get(index["conversations"][key]["lastRowId"], {}) index["previews"][key] = { "text": _content_label(item.get("content"), item.get("content_type")).replace("\n", " ")[:2000], "sender": str(item.get("sender_id") or ""), } def _load_names(databases: dict[str, Path], account: str): revision = tuple((name, _database_revision(databases[name])) for name in ("user.db", "session.db") if name in databases) key = (account, tuple((name, str(path.resolve()).casefold()) for name, path in databases.items() if name in {"user.db", "session.db"})) return _cached("names", key, revision, lambda: _load_names_uncached(databases, account)) def _database_files(databases: dict[str, Path]): revision = tuple((name, _database_revision(path)) for name, path in sorted(databases.items())) key = tuple((name, str(path.resolve()).casefold()) for name, path in sorted(databases.items())) files, size = _cached("files", key, revision, lambda: _database_files_uncached(databases)) return [dict(item) for item in files], size