/** * This file is part of JS8Call. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * (C) 2018 Jordan Sherer - All Rights Reserved * **/ #include "Inbox.h" #include "DriftingDateTime.h" #include Q_DECLARE_LOGGING_CATEGORY(inbox_js8) namespace { constexpr char SCHEMA[] = "CREATE TABLE IF NOT EXISTS inbox_v1 (" " id INTEGER PRIMARY KEY AUTOINCREMENT, " " blob TEXT" ");" "CREATE INDEX IF NOT EXISTS idx_inbox_v1__type ON" " inbox_v1(json_extract(blob, '$.type'));" "CREATE INDEX IF NOT EXISTS idx_inbox_v1__params_from ON" " inbox_v1(json_extract(blob, '$.params.FROM'));" "CREATE INDEX IF NOT EXISTS idx_inbox_v1__params_to ON" " inbox_v1(json_extract(blob, '$.params.TO'));" "CREATE TABLE IF NOT EXISTS inbox_group_recip_v1 (" " id INTEGER PRIMARY KEY AUTOINCREMENT, " " msg_id INTEGER, " " callsign VARCHAR(255), " " FOREIGN KEY(msg_id) REFERENCES inbox_v1(id) ON DELETE CASCADE" ");" "CREATE INDEX IF NOT EXISTS idx_inbox_group_recip_v1__callsign ON" " inbox_group_recip_v1(callsign);"; // Attempt to retrieve a Message object previously serialized as a // JSON object to the specified column; will throw on failure to // deserialize the object. auto get_column_message(sqlite3_stmt *const stmt, int const iCol) { return Message::fromJson( QByteArray((const char *)sqlite3_column_text(stmt, iCol), sqlite3_column_bytes(stmt, iCol))); } } // namespace Inbox::Inbox(QString path) : path_{path}, db_{nullptr} {} Inbox::~Inbox() { close(); } /** * Low-Level Interface **/ bool Inbox::isOpen() { return db_ != nullptr; } bool Inbox::open() { int rc = sqlite3_open(path_.toLocal8Bit().data(), &db_); if (rc != SQLITE_OK) { close(); return false; } rc = sqlite3_exec(db_, SCHEMA, nullptr, nullptr, nullptr); if (rc != SQLITE_OK) { return false; } return true; } void Inbox::close() { if (db_) { sqlite3_close(db_); db_ = nullptr; } } QString Inbox::error() { if (db_) { return QString::fromLocal8Bit(sqlite3_errmsg(db_)); } return ""; } int Inbox::count(QString type, QString query, QString match) { if (!isOpen()) { return -1; } const char *sql = "SELECT COUNT(*) FROM inbox_v1 " "WHERE json_extract(blob, '$.type') = ? " "AND json_extract(blob, ?) LIKE ?;"; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { return -1; } auto t8 = type.toLocal8Bit(); auto q8 = query.toLocal8Bit(); auto m8 = match.toLocal8Bit(); rc = sqlite3_bind_text(stmt, 1, t8.data(), -1, nullptr); rc = sqlite3_bind_text(stmt, 2, q8.data(), -1, nullptr); rc = sqlite3_bind_text(stmt, 3, m8.data(), -1, nullptr); int count = 0; rc = sqlite3_step(stmt); if (rc == SQLITE_ROW) { count = sqlite3_column_int(stmt, 0); } rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { return -1; } return count; } QList> Inbox::values(QString type, QString query, QString match, int offset, int limit) { if (!isOpen()) { return {}; } const char *sql = "SELECT id, blob FROM inbox_v1 " "WHERE json_extract(blob, '$.type') = ? " "AND json_extract(blob, ?) LIKE ? " "ORDER BY id ASC " "LIMIT ? OFFSET ?;"; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { return {}; } auto t8 = type.toLocal8Bit(); auto q8 = query.toLocal8Bit(); auto m8 = match.toLocal8Bit(); rc = sqlite3_bind_text(stmt, 1, t8.data(), -1, nullptr); rc = sqlite3_bind_text(stmt, 2, q8.data(), -1, nullptr); rc = sqlite3_bind_text(stmt, 3, m8.data(), -1, nullptr); rc = sqlite3_bind_int(stmt, 4, limit); rc = sqlite3_bind_int(stmt, 5, offset); // qCDebug(inbox_js8) << "exec" << sqlite3_expanded_sql(stmt); QList> v; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { try { v.append( {sqlite3_column_int(stmt, 0), get_column_message(stmt, 1)}); } catch (...) { continue; } } rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { return {}; } return v; } Message Inbox::value(int key) { if (!isOpen()) { return {}; } const char *sql = "SELECT blob FROM inbox_v1 WHERE id = ? LIMIT 1;"; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { return {}; } rc = sqlite3_bind_int(stmt, 1, key); Message m; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { try { m = get_column_message(stmt, 0); } catch (...) { continue; } } rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { return {}; } return m; } int Inbox::append(Message value) { if (!isOpen()) { return -1; } const char *sql = "INSERT INTO inbox_v1 (blob) VALUES (?);"; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { return -2; } auto j8 = value.toJson(); rc = sqlite3_bind_text(stmt, 1, j8.data(), -1, nullptr); rc = sqlite3_step(stmt); rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { return -1; } return sqlite3_last_insert_rowid(db_); } bool Inbox::set(int key, Message value) { if (!isOpen()) { return false; } const char *sql = "UPDATE inbox_v1 SET blob = ? WHERE id = ?;"; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { return false; } auto j8 = value.toJson(); rc = sqlite3_bind_text(stmt, 1, j8.data(), -1, nullptr); rc = sqlite3_bind_int(stmt, 2, key); rc = sqlite3_step(stmt); rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { return false; } return true; } bool Inbox::del(int key) { if (!isOpen()) { return false; } const char *sql = "DELETE FROM inbox_v1 WHERE id = ?;"; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { return false; } rc = sqlite3_bind_int(stmt, 1, key); rc = sqlite3_step(stmt); rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { return false; } return true; } int Inbox::getLookaheadMessageIdForCallsign(const QString &callsign, int afterMsgId) { if (!isOpen()) { return -1; } const char *sql = "SELECT inbox_v1.id, inbox_v1.blob FROM inbox_v1 " "WHERE inbox_v1.id > ? " "AND json_extract(blob, '$.type') = 'STORE' " "AND json_extract(blob, '$.params.TO') LIKE ? " "ORDER BY inbox_v1.id ASC " "LIMIT ? OFFSET ?;"; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { return -1; } auto c8 = callsign.toLocal8Bit(); rc = sqlite3_bind_int(stmt, 1, afterMsgId); rc = sqlite3_bind_text(stmt, 2, c8.data(), -1, nullptr); rc = sqlite3_bind_int(stmt, 3, 10); rc = sqlite3_bind_int(stmt, 4, 0); // qCDebug(inbox_js8) << "exec " << sqlite3_expanded_sql(stmt); int next_id = -1; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { Message m; int i = sqlite3_column_int(stmt, 0); auto msg = QByteArray((const char *)sqlite3_column_text(stmt, 1), sqlite3_column_bytes(stmt, 1)); m = Message::fromJson(msg); auto params = m.params(); auto text = params.value("TEXT").toString().trimmed(); if (!text.isEmpty()) { next_id = i; break; } } rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { return -1; } return next_id; } /** * High-Level Interface **/ int Inbox::countUnreadFrom(QString from) { return count("UNREAD", "$.params.FROM", from); } QPair Inbox::firstUnreadFrom(QString from) { auto v = values("UNREAD", "$.params.FROM", from, 0, 1); if (v.isEmpty()) { return {}; } return v.first(); } QMap Inbox::getGroupMessageCounts() { if (!isOpen()) { return {}; } QMap messageCounts; const char *sql = "SELECT count(id) as msg_count, json_extract(blob, " "'$.params.TO') as group_name FROM inbox_v1 " "WHERE json_extract(blob, '$.type') = 'STORE' " "AND group_name LIKE '@%' " "AND json_extract(blob, '$.params.UTC') > ? " "GROUP BY group_name"; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { return messageCounts; } // Set a floor or 48 hours for group message retrieval // TODO: date formatting with the "yyyy-MM-dd HH:mm:ss" string happens // elsewhere as well, centralize // TODO: possibly make the date floor configurable auto d8 = DriftingDateTime::currentDateTimeUtc() .addDays(-2) .toString("yyyy-MM-dd HH:mm:ss") .toLocal8Bit(); rc = sqlite3_bind_text(stmt, 1, d8.data(), -1, nullptr); // qCDebug(inbox_js8) << "exec " << sqlite3_expanded_sql(stmt); while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { Message m; int count = sqlite3_column_int(stmt, 0); auto group = sqlite3_column_text(stmt, 1); messageCounts.insert( QString::fromLocal8Bit(reinterpret_cast(group)), count); } rc = sqlite3_finalize(stmt); return messageCounts; } bool Inbox::markGroupMsgDeliveredForCallsign(int msgId, QString callsign) { if (!isOpen()) { return false; } const char *sql = "SELECT count(id) as msg_count FROM inbox_group_recip_v1 " "WHERE msg_id = ? AND callsign = ? LIMIT 1;"; sqlite3_stmt *exists_stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &exists_stmt, nullptr); if (rc != SQLITE_OK) { return false; } auto cs8 = callsign.toLocal8Bit(); rc = sqlite3_bind_int(exists_stmt, 1, msgId); rc = sqlite3_bind_text(exists_stmt, 2, cs8.data(), -1, nullptr); bool recordExists = false; rc = sqlite3_step(exists_stmt); int count = sqlite3_column_int(exists_stmt, 0); recordExists = (count > 0); rc = sqlite3_finalize(exists_stmt); if (!recordExists) { sql = "INSERT INTO inbox_group_recip_v1 (msg_id, callsign) VALUES (?,?);"; sqlite3_stmt *insert_stmt; rc = sqlite3_prepare_v2(db_, sql, -1, &insert_stmt, nullptr); if (rc != SQLITE_OK) { return false; } rc = sqlite3_bind_int(insert_stmt, 1, msgId); rc = sqlite3_bind_text(insert_stmt, 2, cs8.data(), -1, nullptr); rc = sqlite3_step(insert_stmt); rc = sqlite3_finalize(insert_stmt); if (rc != SQLITE_OK) { return false; } } return true; } int Inbox::getNextGroupMessageIdForCallsign(const QString &group_name, const QString &callsign) { if (!isOpen()) { return -1; } const char *sql = "SELECT inbox_v1.id, inbox_v1.blob FROM inbox_v1 " "LEFT JOIN inbox_group_recip_v1 ON " "(inbox_group_recip_v1.msg_id=inbox_v1.id AND " "inbox_group_recip_v1.callsign = ?) " "WHERE json_extract(blob, '$.type') = 'STORE' " "AND json_extract(blob, '$.params.TO') LIKE ? " "AND json_extract(blob, '$.params.UTC') > ? " "AND inbox_group_recip_v1.id IS NULL " "ORDER BY inbox_v1.id ASC " "LIMIT ? OFFSET ?;"; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { return -1; } auto c8 = callsign.toLocal8Bit(); auto g8 = group_name.toLocal8Bit(); // Set a floor or 48 hours for group message retrieval // TODO: date formatting with the "yyyy-MM-dd HH:mm:ss" string happens // elsewhere as well, centralize // TODO: possibly make the date floor configurable auto d8 = DriftingDateTime::currentDateTimeUtc() .addDays(-2) .toString("yyyy-MM-dd HH:mm:ss") .toLocal8Bit(); rc = sqlite3_bind_text(stmt, 1, c8.data(), -1, nullptr); rc = sqlite3_bind_text(stmt, 2, g8.data(), -1, nullptr); rc = sqlite3_bind_text(stmt, 3, d8.data(), -1, nullptr); rc = sqlite3_bind_int(stmt, 4, 10); rc = sqlite3_bind_int(stmt, 5, 0); // qCDebug(inbox_js8) << "exec " << sqlite3_expanded_sql(stmt); int next_id = -1; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { Message m; int i = sqlite3_column_int(stmt, 0); auto msg = QByteArray((const char *)sqlite3_column_text(stmt, 1), sqlite3_column_bytes(stmt, 1)); m = Message::fromJson(msg); auto params = m.params(); auto text = params.value("TEXT").toString().trimmed(); if (!text.isEmpty()) { next_id = i; break; } } rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { return -1; } return next_id; } int Inbox::getLookaheadGroupMessageIdForCallsign(const QString &group_name, const QString &callsign, int afterMsgId) { if (!isOpen()) { return -1; } const char *sql = "SELECT inbox_v1.id, inbox_v1.blob FROM inbox_v1 " "LEFT JOIN inbox_group_recip_v1 ON " "(inbox_group_recip_v1.msg_id=inbox_v1.id AND " "inbox_group_recip_v1.callsign = ?) " "WHERE inbox_v1.id > ? " "AND json_extract(blob, '$.type') = 'STORE' " "AND json_extract(blob, '$.params.TO') LIKE ? " "AND json_extract(blob, '$.params.UTC') > ? " "AND inbox_group_recip_v1.id IS NULL " "ORDER BY inbox_v1.id ASC " "LIMIT ? OFFSET ?;"; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { return -1; } auto c8 = callsign.toLocal8Bit(); auto g8 = group_name.toLocal8Bit(); // Set a floor or 48 hours for group message retrieval // TODO: date formatting with the "yyyy-MM-dd HH:mm:ss" string happens // elsewhere as well, centralize // TODO: possibly make the date floor configurable auto d8 = DriftingDateTime::currentDateTimeUtc() .addDays(-2) .toString("yyyy-MM-dd HH:mm:ss") .toLocal8Bit(); rc = sqlite3_bind_text(stmt, 1, c8.data(), -1, nullptr); rc = sqlite3_bind_int(stmt, 2, afterMsgId); rc = sqlite3_bind_text(stmt, 3, g8.data(), -1, nullptr); rc = sqlite3_bind_text(stmt, 4, d8.data(), -1, nullptr); rc = sqlite3_bind_int(stmt, 5, 10); rc = sqlite3_bind_int(stmt, 6, 0); qCDebug(inbox_js8) << "exec " << sqlite3_expanded_sql(stmt); int next_id = -1; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { Message m; int i = sqlite3_column_int(stmt, 0); auto msg = QByteArray((const char *)sqlite3_column_text(stmt, 1), sqlite3_column_bytes(stmt, 1)); m = Message::fromJson(msg); auto params = m.params(); auto text = params.value("TEXT").toString().trimmed(); if (!text.isEmpty()) { next_id = i; break; } } rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { return -1; } return next_id; } Q_LOGGING_CATEGORY(inbox_js8, "inbox.js8", QtWarningMsg)