#include "db_manager.h" #include "../src/debug_ui.h" #include "../../lib/debug_config.h" #include #include #include #include #include #include #include "chat/chat_poll.h" #include "chat/group_avatar.h" /* ── helpers ── */ static QByteArray parseJsonField(const QByteArray& json, const char* key) { return QJsonDocument::fromJson(json).object().value(QString::fromLatin1(key)).toString().toUtf8(); } QString DbManager::sanitizeTableName(const QString& chId) { QString s; s.reserve(chId.size()); for (int i = 0; i < chId.size(); i++) { QChar c = chId[i]; if (c.isLetterOrNumber() || c == '_') s += c; else s += '_'; } return s; } QString DbManager::msgTableName(const QString& chId) const { return QStringLiteral("msg_") + sanitizeTableName(chId); } QString DbManager::peersTableName(const QString& chId) const { return QStringLiteral("peers_") + sanitizeTableName(chId); } /* ── lifecycle ── */ DbManager::DbManager(QObject* parent) : QObject(parent) { } DbManager::~DbManager() { DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "DbManager: destroyed"); } sqlite3_stmt* DbManager::prepareOrNull(const char* sql) const { if (!m_db) { GUI_DEBUG("read skipped: database unavailable sql=%s", sql); return nullptr; } sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(m_db, sql, -1, &stmt, nullptr) != SQLITE_OK) { const char* err = sqlite3_errmsg(m_db); if (strstr(err, "no such table")) GUI_DEBUG("optional table unavailable: %s — %s", sql, err); else GUI_ERROR("prepare failed: %s — %s", sql, err); sqlite3_finalize(stmt); return nullptr; } if (!sqlite3_stmt_readonly(stmt)) { GUI_ERROR("GUI write rejected: %s", sql); sqlite3_finalize(stmt); return nullptr; } return stmt; } /* Ошибки bind/step/finalize не превращаются в частичный успешный снимок. */ static bool finishRead(sqlite3_stmt* statement, int result) { sqlite3* db = sqlite3_db_handle(statement); const QByteArray sql = sqlite3_sql(statement); const bool ok = result == SQLITE_ROW || result == SQLITE_DONE; if (!ok) GUI_ERROR("read failed: rc=%d sql=%s error=%s", result, sql.constData(), sqlite3_errmsg(db)); const int final = sqlite3_finalize(statement); if (final != SQLITE_OK) GUI_ERROR("read finalize failed: rc=%d sql=%s error=%s", final, sql.constData(), sqlite3_errmsg(db)); return ok && final == SQLITE_OK; } QString DbManager::colText(sqlite3_stmt* stmt, int col) { return QString::fromUtf8( reinterpret_cast(sqlite3_column_text(stmt, col))); } QByteArray DbManager::colBlob(sqlite3_stmt* stmt, int col) { int len = sqlite3_column_bytes(stmt, col); if (len <= 0) return {}; return QByteArray(static_cast(sqlite3_column_blob(stmt, col)), len); } /* ── channels ── */ QList DbManager::getChannels(ChannelSortMode sort) const { QList list; const char* orderBy = (sort == ChannelSortMode::Recent) ? " ORDER BY last_msg_at DESC, name COLLATE NOCASE ASC" : " ORDER BY name COLLATE NOCASE ASC"; char sql[512]; snprintf(sql, sizeof(sql), "SELECT channel_id, name, owner_node_id," " x25519_pubkey, x25519_privkey, ed25519_pubkey, ed25519_privkey, signature," " last_read_msg_id, last_pos_msg_id, last_msg_at, created_at,avatar_version,avatar_hash" " FROM channels%s", orderBy); sqlite3_stmt* stmt = prepareOrNull(sql); if (!stmt) return list; int rc; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { ChannelRow r; r.channelId = colText(stmt, 0); r.name = colText(stmt, 1); r.ownerNodeId = (quint64)sqlite3_column_int64(stmt, 2); r.x25519_pubkey = colBlob(stmt, 3); r.x25519_privkey = colBlob(stmt, 4); r.ed25519_pubkey = colBlob(stmt, 5); r.ed25519_privkey = colBlob(stmt, 6); r.signature = colBlob(stmt, 7); r.lastReadMsgId = sqlite3_column_int64(stmt, 8); r.lastPosMsgId = sqlite3_column_int64(stmt, 9); r.lastMsgAt = sqlite3_column_int64(stmt, 10); r.createdAt = sqlite3_column_int64(stmt, 11); r.avatarVersion = (quint64)sqlite3_column_int64(stmt, 12); r.avatarHash = colBlob(stmt, 13); list.append(r); } if (!finishRead(stmt, rc)) return {}; for (auto& ch : list) { ch.lastReadMsgId = getUiStateInt64( QStringLiteral("ch_%1_last_read").arg(ch.channelId), 0); ch.lastPosMsgId = getUiStateInt64( QStringLiteral("ch_%1_last_pos").arg(ch.channelId), 0); ch.unreadCount = getUnreadCount(ch.channelId, ch.lastReadMsgId); } return list; } QByteArray DbManager::getChannelAvatar(const QString& channelId, bool* ok) const { if (ok) *ok = false; if (!m_db) { GUI_WARN("group avatar: database unavailable channel=%s", qPrintable(channelId)); return {}; } sqlite3_stmt* st = nullptr; const char* stage = "prepare"; int rc = sqlite3_prepare_v2(m_db, "SELECT avatar_data,avatar_version FROM channels WHERE channel_id=?", -1, &st, nullptr); const QByteArray id = channelId.toLatin1(); if (rc == SQLITE_OK) { stage = "bind"; rc = sqlite3_bind_text(st, 1, id.constData(), id.size(), SQLITE_TRANSIENT); } if (rc == SQLITE_OK) { stage = "step"; rc = sqlite3_step(st); } QByteArray bytes; const char* detail = nullptr; int type = -1, length = 0; if (rc == SQLITE_ROW) { stage = "blob"; type = sqlite3_column_type(st, 0); length = sqlite3_column_bytes(st, 0); if (type == SQLITE_NULL && sqlite3_column_int64(st, 1) == 0) bytes.clear(); // Аватар ещё не публиковался else if (type != SQLITE_BLOB) { rc = SQLITE_CORRUPT; detail = "expected a BLOB or NULL for an unpublished avatar"; } else if (length > GROUP_AVATAR_MAX) { rc = SQLITE_TOOBIG; detail = "avatar exceeds the protocol size limit"; } else { const char* blob = static_cast(sqlite3_column_blob(st, 0)); if (length && !blob) { rc = SQLITE_NOMEM; detail = "SQLite returned no BLOB data"; } else bytes = QByteArray(blob, length); } } const int final = sqlite3_finalize(st); if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) { GUI_ERROR("group avatar: read channel=%s stage=%s rc=%d finalize=%d type=%d bytes=%d limit=%d: %s", qPrintable(channelId), stage, rc, final, type, length, GROUP_AVATAR_MAX, detail ? detail : sqlite3_errmsg(m_db)); return {}; } if (rc == SQLITE_DONE) { GUI_DEBUG("group avatar: channel disappeared during read channel=%s", qPrintable(channelId)); return {}; } if (ok) *ok = true; return bytes; } bool DbManager::isChannelMember(const QString& chId, quint64 nodeId) const { const QByteArray sql = QStringLiteral("SELECT 1 FROM \"%1\" WHERE node_id=?").arg(peersTableName(chId)).toUtf8(); sqlite3_stmt* statement = prepareOrNull(sql.constData()); if (!statement) return false; const int bound = sqlite3_bind_int64(statement, 1, (sqlite3_int64)nodeId); const int result = bound == SQLITE_OK ? sqlite3_step(statement) : bound; return finishRead(statement, result) && result == SQLITE_ROW; } /* Формирует человекочитаемое тело превью последнего сообщения по content_type и "d"-полю. */ static QString mediaPreviewBody(const QString& ct, const QByteArray& d) { bool isText = ct.isEmpty() || ct == QStringLiteral("text/plain") || ct == QStringLiteral("voice_transcription"); if (isText) { QString t = QString::fromUtf8(d); if (t.size() > 60) { t = t.left(60); t += QStringLiteral("..."); } return t; } if (ct == QStringLiteral("audio/opus")) { int durMs = 0; if (d.startsWith('*')) { int pipe = d.indexOf('|'); if (pipe > 1) { QByteArray b64 = d.mid(1, pipe - 1); b64.replace('-', '+'); b64.replace('_', '/'); QString raw = QString::fromUtf8(QByteArray::fromBase64(b64)); int durPos = raw.indexOf(QStringLiteral(";dur=")); if (durPos >= 0) { int end = raw.indexOf(';', durPos + 5); QString ds = (end > 0) ? raw.mid(durPos + 5, end - durPos - 5) : raw.mid(durPos + 5); durMs = ds.toInt(); } } } if (durMs > 0) { int s = durMs / 1000; return QStringLiteral("Voice message (%1:%2)") .arg(s / 60).arg(s % 60, 2, 10, QLatin1Char('0')); } return QStringLiteral("Voice message"); } if (ct.startsWith(QStringLiteral("video/"))) return QStringLiteral("Video"); if (ct.startsWith(QStringLiteral("image/"))) return QStringLiteral("Photo"); /* файл и прочие нетекстовые: base64-префикс — имя файла */ if (d.startsWith('*')) { int pipe = d.indexOf('|'); QByteArray b64 = d.mid(1, (pipe > 1) ? pipe - 1 : d.size() - 1); b64.replace('-', '+'); b64.replace('_', '/'); QString fname = QString::fromUtf8(QByteArray::fromBase64(b64)); if (!fname.isEmpty()) return fname; } return QStringLiteral("File"); } QString DbManager::getChannelLastMessage(const QString& channelId) const { QString sql = QStringLiteral( "SELECT node_id, data FROM \"%1\" ORDER BY id DESC LIMIT 1") .arg(msgTableName(channelId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return {}; QString result; const int rc = sqlite3_step(stmt); if (rc == SQLITE_ROW) { quint64 author = (quint64)sqlite3_column_int64(stmt, 0); QByteArray jdata = colBlob(stmt, 1); QString ct = QString::fromUtf8(parseJsonField(jdata, "ct")); QByteArray d = parseJsonField(jdata, "d"); QString nick = getDisplayName(author); result = nick + QStringLiteral(": ") + mediaPreviewBody(ct, d); } return finishRead(stmt, rc) ? result : QString(); } /* ── messages (UI) ── */ /* Единое чтение подписанного сообщения для истории, переходов и разрешения цитат. */ static MessageRow readMessage(sqlite3_stmt* stmt, quint64 myId) { MessageRow m; m.id = sqlite3_column_int64(stmt, 0); m.authorNodeId = (quint64)sqlite3_column_int64(stmt, 1); const auto json = QJsonDocument::fromJson(DbManager::colBlob(stmt, 2)).object(); m.contentType = json.value("ct").toString("text/plain"); m.data = json.value("d").toString().toUtf8(); if (m.contentType.isEmpty()) m.contentType = "text/plain"; const QByteArray reply = json.value("reply_to").toString().toLatin1(); if (!reply.isEmpty()) { m.replyTo = QByteArray::fromHex(reply); if (reply.size() != 64 || m.replyTo.size() != 32 || m.replyTo.toHex() != reply) { GUI_WARN("reply: malformed reference message_row=%lld", (long long)m.id); m.replyTo.clear(); } } m.timestamp = sqlite3_column_int64(stmt, 3); m.signature = DbManager::colBlob(stmt, 4); m.localAttrs = DbManager::colBlob(stmt, 5); m.messageId = DbManager::colBlob(stmt, 6); m.version = quint64(sqlite3_column_int64(stmt, 7)); m.editedAt = sqlite3_column_int64(stmt, 8); m.isOutgoing = m.authorNodeId == myId; m.isRead = true; return m; } /* Окно вокруг оригинала выбирается одним SQL snapshot, включая одинаковые timestamp. */ QList DbManager::getMessages(const QString& chId, int limit, const QByteArray& around) const { QList list; if (limit <= 0 || (!around.isEmpty() && around.size() != 32)) { GUI_ERROR("history: invalid anchor/window bytes=%d limit=%d", int(around.size()), limit); return list; } QString sql = QStringLiteral( "SELECT id, node_id, data, timestamp, author_signature, local_attrs, message_id, ver, edited_at" " FROM \"%1\" ORDER BY timestamp DESC, message_id DESC LIMIT ?1").arg(msgTableName(chId)); if (!around.isEmpty()) sql += QStringLiteral( " OFFSET min(max(0,(SELECT count(*) FROM \"%1\" n JOIN \"%1\" a ON a.message_id=?2 " "WHERE (n.timestamp,n.message_id)>(a.timestamp,a.message_id)) - ?1/2)," "max(0,(SELECT count(*) FROM \"%1\")-?1))").arg(msgTableName(chId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return list; int rc = sqlite3_bind_int(stmt, 1, limit); if (rc == SQLITE_OK && !around.isEmpty()) rc = sqlite3_bind_blob(stmt, 2, around.constData(), around.size(), SQLITE_TRANSIENT); if (rc != SQLITE_OK) { finishRead(stmt, rc); return {}; } uint64_t myId = m_myNodeId; if (!myId) { sqlite3_stmt* identity = prepareOrNull("SELECT node_id FROM local_identity WHERE id=1"); if (identity) { const int row = sqlite3_step(identity); if (row == SQLITE_ROW) myId = (uint64_t)sqlite3_column_int64(identity, 0); if (!finishRead(identity, row)) { finishRead(stmt, SQLITE_ABORT); return {}; } } } while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { list.prepend(readMessage(stmt, myId)); } if (!finishRead(stmt, rc)) return {}; if (!around.isEmpty()) GUI_DEBUG("history: window loaded ch=%s anchor=%s rows=%d limit=%d", qPrintable(chId), around.toHex().constData(), int(list.size()), limit); return list; } QList DbManager::getMessagesSince(const QString& chId, qint64 sinceId) const { QList list; QString sql = QStringLiteral( "SELECT id, node_id, data, timestamp, author_signature, local_attrs, message_id, ver, edited_at" " FROM \"%1\" WHERE id > ? ORDER BY id ASC").arg(msgTableName(chId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return list; int rc = sqlite3_bind_int64(stmt, 1, sinceId); if (rc != SQLITE_OK) { finishRead(stmt, rc); return {}; } uint64_t myId = m_myNodeId; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { list.append(readMessage(stmt, myId)); } return finishRead(stmt, rc) ? list : QList(); } MessageRow DbManager::getMessageById(const QString& chId, qint64 msgId) const { MessageRow m; QString sql = QStringLiteral( "SELECT id, node_id, data, timestamp, author_signature, local_attrs, message_id, ver, edited_at" " FROM \"%1\" WHERE id = ?").arg(msgTableName(chId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return m; int rc = sqlite3_bind_int64(stmt, 1, msgId); if (rc == SQLITE_OK) rc = sqlite3_step(stmt); if (rc == SQLITE_ROW) { m = readMessage(stmt, m_myNodeId); } return finishRead(stmt, rc) ? m : MessageRow(); } /* Метаданные и состояние ключа читаются одним SELECT, курсор закрыт до передачи данных UI. */ AttachmentRow DbManager::getAttachment(const QString& channel, const QByteArray& messageId) const { AttachmentRow row; if (messageId.size() != 32) { GUI_ERROR("attachment lookup: invalid key bytes=%d", int(messageId.size())); return row; } sqlite3_stmt* st = prepareOrNull("SELECT a.ready,a.downloaded,a.decrypting,a.error,a.plain_path,a.info,a.content_type," "COALESCE(r.state,-1),length(a.key)=32 FROM attachment_keys a LEFT JOIN attachment_key_requests r" " ON r.channel=a.channel AND r.message_id=a.message_id AND r.incoming=0 WHERE a.channel=? AND a.message_id=?"); if (!st) return row; const QByteArray bytes = channel.toUtf8(); int rc = sqlite3_bind_text(st, 1, bytes.constData(), bytes.size(), SQLITE_TRANSIENT); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, messageId.constData(), messageId.size(), SQLITE_TRANSIENT); if (rc == SQLITE_OK) rc = sqlite3_step(st); if (rc == SQLITE_ROW) { row.found = true; row.ready = sqlite3_column_int(st, 0); row.downloaded = sqlite3_column_int(st, 1); row.decrypting = sqlite3_column_int(st, 2); row.error = sqlite3_column_int(st, 3); row.path = colText(st, 4); row.info = colBlob(st, 5); row.contentType = colText(st, 6); row.requestState = sqlite3_column_int(st, 7); row.hasKey = sqlite3_column_int(st, 8); } return finishRead(st, rc) ? row : AttachmentRow(); } /* ── DM-беседы (UI, read-only) ── */ /* Поиск ограничен таблицей выбранного канала; отсутствие оригинала допустимо при неполной синхронизации. */ MessageRow DbManager::getMessageByKey(const QString& chId, const QByteArray& key) const { MessageRow message; if (key.size() != 32) { GUI_ERROR("reply: invalid lookup key bytes=%d", int(key.size())); return message; } const auto sql = QStringLiteral("SELECT id,node_id,data,timestamp,author_signature,local_attrs,message_id,ver,edited_at " "FROM \"%1\" WHERE message_id=?").arg(msgTableName(chId)).toUtf8(); sqlite3_stmt* stmt = prepareOrNull(sql.constData()); if (!stmt) return message; int rc = sqlite3_bind_blob(stmt, 1, key.constData(), key.size(), SQLITE_TRANSIENT); if (rc == SQLITE_OK) rc = sqlite3_step(stmt); if (rc == SQLITE_ROW) message = readMessage(stmt, m_myNodeId); return finishRead(stmt, rc) ? message : MessageRow(); } /* Короткий обычный текст без рекурсивных цитат и служебного описания медиа. */ QString DbManager::messagePreview(const MessageRow& message) { const auto& ct = message.contentType; QString text; if (ct == "poll") { struct chat_poll poll; const auto wire = QByteArray::fromBase64(message.data.mid(4), QByteArray::Base64UrlEncoding); if (chat_poll_decode(reinterpret_cast(wire.constData()), wire.size(), &poll) == 0) text = QString::fromUtf8(poll.question); else text = QCoreApplication::translate("ChatGui", "Invalid poll"); } else if (ct == "audio/opus") text = QCoreApplication::translate("ChatGui", "Voice message"); else if (ct.startsWith("image/")) text = QCoreApplication::translate("ChatGui", "Photo"); else if (ct.startsWith("video/")) text = QCoreApplication::translate("ChatGui", "Video"); else text = mediaPreviewBody(ct, message.data); return text.simplified().left(160); } QList DbManager::getDmConversations() const { QList list; if (!m_db) return list; sqlite3_stmt* stmt = prepareOrNull( "SELECT c.conv_id, peer_node_id, COALESCE(peer_name,''), last_ts, last_out_seq, last_in_seq, COALESCE(h.generation,1)" " FROM dm_conversations c LEFT JOIN dm_history h USING(conv_id) WHERE COALESCE(h.hidden,0)=0 ORDER BY last_ts DESC"); if (!stmt) return list; int rc; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { DmConversation c; c.convId = colText(stmt, 0); c.peerNodeId = (quint64)sqlite3_column_int64(stmt, 1); c.peerName = colText(stmt, 2); c.lastTs = sqlite3_column_int64(stmt, 3); c.lastOutSeq = sqlite3_column_int64(stmt, 4); c.lastInSeq = sqlite3_column_int64(stmt, 5); c.generation = (quint64)sqlite3_column_int64(stmt, 6); list.append(c); } return finishRead(stmt, rc) ? list : QList(); } /* ── peers (UI) ── */ QMap DbManager::getLocalNicknames(const QString& chId) const { QMap m; if (!m_db) return m; QString sql = QStringLiteral( "SELECT node_id, local_nick FROM \"%1\" WHERE local_nick IS NOT NULL AND local_nick != ''" ).arg(peersTableName(chId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return m; int rc; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) m.insert((quint64)sqlite3_column_int64(stmt, 0), colText(stmt, 1)); return finishRead(stmt, rc) ? m : QMap(); } QString DbManager::getDisplayName(quint64 nodeId) const { for (const char* sql : {"SELECT display_name FROM accounts WHERE node_id=?", "SELECT name FROM nodes WHERE node_id=?"}) { sqlite3_stmt* st = prepareOrNull(sql); if (!st) continue; int rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)nodeId); if (rc == SQLITE_OK) rc = sqlite3_step(st); QString name = rc == SQLITE_ROW ? colText(st, 0) : QString(); if (!finishRead(st, rc)) break; if (!name.isEmpty()) return name; } return QString("node_%1").arg(nodeId); } QByteArray DbManager::getNodeEdPub(quint64 nodeId) const { sqlite3_stmt* stmt = prepareOrNull("SELECT ed25519_pubkey FROM nodes WHERE node_id=?"); if (!stmt) return {}; int rc = sqlite3_bind_int64(stmt, 1, (sqlite3_int64)nodeId); if (rc == SQLITE_OK) rc = sqlite3_step(stmt); QByteArray r; if (rc == SQLITE_ROW) r = colBlob(stmt, 0); if (!finishRead(stmt, rc)) return {}; if (!r.isEmpty() && r.size() != 32) { GUI_ERROR("node signing key has invalid length=%d", int(r.size())); return {}; } return r; } /* ── accounts ── */ QList DbManager::getAccounts(bool contactsOnly) const { QList list; const char* sql = contactsOnly ? "SELECT node_id, display_name, avatar_color, avatar_letter, is_contact" " FROM accounts WHERE is_contact=1 ORDER BY display_name" : "SELECT node_id, display_name, avatar_color, avatar_letter, is_contact" " FROM accounts ORDER BY display_name"; sqlite3_stmt* stmt = prepareOrNull(sql); if (!stmt) return list; int rc; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { AccountRow a; a.nodeId = (quint64)sqlite3_column_int64(stmt, 0); a.displayName = colText(stmt, 1); a.avatarColor = colText(stmt, 2); a.avatarLetter = colText(stmt, 3); a.isContact = sqlite3_column_int(stmt, 4) != 0; list.append(a); } return finishRead(stmt, rc) ? list : QList(); } AccountRow DbManager::getAccount(quint64 nodeId) const { AccountRow a; sqlite3_stmt* stmt = prepareOrNull( "SELECT node_id, display_name, avatar_color, avatar_letter, is_contact" " FROM accounts WHERE node_id=?"); if (!stmt) return a; int rc = sqlite3_bind_int64(stmt, 1, (sqlite3_int64)nodeId); if (rc == SQLITE_OK) rc = sqlite3_step(stmt); if (rc == SQLITE_ROW) { a.nodeId = (quint64)sqlite3_column_int64(stmt, 0); a.displayName = colText(stmt, 1); a.avatarColor = colText(stmt, 2); a.avatarLetter = colText(stmt, 3); a.isContact = sqlite3_column_int(stmt, 4) != 0; } return finishRead(stmt, rc) ? a : AccountRow(); } /* ── ui_state ── */ QString DbManager::getUiState(const QString& key) const { sqlite3_stmt* stmt = prepareOrNull("SELECT value FROM ui_state WHERE key=?"); if (!stmt) return {}; int rc = sqlite3_bind_text(stmt, 1, key.toUtf8().constData(), -1, SQLITE_TRANSIENT); if (rc == SQLITE_OK) rc = sqlite3_step(stmt); QString val; if (rc == SQLITE_ROW) val = colText(stmt, 0); return finishRead(stmt, rc) ? val : QString(); } int DbManager::getUiStateInt(const QString& key, int defaultVal) const { QString val = getUiState(key); if (val.isEmpty()) return defaultVal; bool ok = false; int v = val.toInt(&ok); return ok ? v : defaultVal; } qint64 DbManager::getUiStateInt64(const QString& key, qint64 defaultVal) const { QString val = getUiState(key); if (val.isEmpty()) return defaultVal; bool ok = false; qint64 v = val.toLongLong(&ok); return ok ? v : defaultVal; } int DbManager::getUnreadCount(const QString& channelId, qint64 lastReadMsgId) const { QString sql = QStringLiteral( "SELECT COUNT(*) FROM \"%1\" WHERE id > ?").arg(msgTableName(channelId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return 0; int rc = sqlite3_bind_int64(stmt, 1, lastReadMsgId); if (rc == SQLITE_OK) rc = sqlite3_step(stmt); int count = 0; if (rc == SQLITE_ROW) count = sqlite3_column_int(stmt, 0); return finishRead(stmt, rc) ? count : 0; } /* ── integrity checks ── */ enum class PragmaMode { IntegrityCheck, Command }; /* SQLite сообщает нарушения целостности строками даже при SQLITE_OK; сохраняем первое и дополнительные сообщения. */ static int integrityCallback(void* data, int nCols, char** values, char** /*names*/) { auto* result = (IntegrityResult*)data; for (int i = 0; i < nCols; i++) { if (values[i]) { QString line = QString::fromUtf8(values[i]); if (result->message.isEmpty()) result->message = line; else if (line != "ok") result->errors.append(line); } } return 0; } /* Проверки требуют ответа ok; у команды обслуживания успех определяется завершением SQL, без обязательных строк. */ static IntegrityResult runPragma(sqlite3* db, const char* pragma, PragmaMode mode) { IntegrityResult result; DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "DB maintenance begin operation=%s db=%p integrity=%d", pragma, static_cast(db), mode == PragmaMode::IntegrityCheck); if (!db) { result.message = "Database not open"; DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "DB maintenance failed operation=%s: database not open", pragma); return result; } QElapsedTimer timer; timer.start(); char* errMsg = nullptr; const int rc = sqlite3_exec(db, pragma, mode == PragmaMode::IntegrityCheck ? integrityCallback : nullptr, &result, &errMsg); result.elapsedMs = (int)timer.elapsed(); if (rc != SQLITE_OK) { result.message = QString::fromUtf8(errMsg ? errMsg : sqlite3_errmsg(db)); DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "DB maintenance failed operation=%s rc=%d elapsed=%dms: %s", pragma, rc, result.elapsedMs, qPrintable(result.message)); sqlite3_free(errMsg); return result; } sqlite3_free(errMsg); if (mode == PragmaMode::Command) { result.ok = true; result.message = "ok"; } else { result.ok = result.message == "ok" && result.errors.isEmpty(); if (result.message.isEmpty()) result.message = "Integrity check returned no result"; } if (result.ok) { DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "DB maintenance complete operation=%s rc=%d elapsed=%dms result=ok", pragma, rc, result.elapsedMs); } else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "DB maintenance failed operation=%s rc=%d elapsed=%dms extra_errors=%d: %s", pragma, rc, result.elapsedMs, int(result.errors.size()), qPrintable(result.message)); for (const auto& error : result.errors) DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "DB maintenance detail operation=%s: %s", pragma, qPrintable(error)); } return result; } IntegrityResult DbManager::integrityCheckQuick() const { return runPragma(m_db, "PRAGMA quick_check", PragmaMode::IntegrityCheck); } IntegrityResult DbManager::integrityCheckFull() const { return runPragma(m_db, "PRAGMA integrity_check", PragmaMode::IntegrityCheck); } IntegrityResult DbManager::integrityOptimize() const { return runPragma(m_db, "PRAGMA optimize", PragmaMode::Command); }