Files
utun2/tools/chatgui/db/db_manager.cpp
T

599 lines
28 KiB
C++

#include "db_manager.h"
#include "../src/debug_ui.h"
#include "../../lib/debug_config.h"
#include <cstring>
#include <QSet>
#include <QElapsedTimer>
#include <QJsonDocument>
#include <QJsonObject>
#include <QCoreApplication>
#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<const char*>(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<const char*>(sqlite3_column_blob(stmt, col)), len);
}
/* ── channels ── */
QList<ChannelRow> DbManager::getChannels(ChannelSortMode sort) const {
QList<ChannelRow> 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<const char*>(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<MessageRow> DbManager::getMessages(const QString& chId, int limit, const QByteArray& around) const {
QList<MessageRow> 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<MessageRow> DbManager::getMessagesSince(const QString& chId, qint64 sinceId) const {
QList<MessageRow> 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>();
}
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<const uint8_t*>(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<DmConversation> DbManager::getDmConversations() const {
QList<DmConversation> 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<DmConversation>();
}
/* ── peers (UI) ── */
QMap<quint64, QString> DbManager::getLocalNicknames(const QString& chId) const {
QMap<quint64, QString> 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<quint64, QString>();
}
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<AccountRow> DbManager::getAccounts(bool contactsOnly) const {
QList<AccountRow> 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>();
}
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<void*>(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);
}