#include #include #include #include #include #include "global.h" #include "action.h" #include "fetchconfigaction.h" #include "fetchforumsaction.h" #include "loginaction.h" #include "newtopicaction.h" #include "newpostaction.h" #include "xmlrpcinterface.h" #include "board.h" const QLatin1String Board::CURRENT_DB_VERSION("testing1"); Board::Board(QObject *parent) : QObject(parent) { } Board::Board(const QUrl& url, const QString& username, const QString& password, QObject *parent) : QObject(parent), _url(url), _slug(createSlug(url)), _db(QSqlDatabase::addDatabase("QSQLITE", _slug)), _iface(new XmlRpcInterface(QUrl(_url), this)) { _db.setDatabaseName(QDir::toNativeSeparators(getDbPathFor(_slug))); qDebug() << "Opening database file" << _db.databaseName() << "for" << _url; if (!_db.open()) { qWarning() << "Could not open database file" << _db.databaseName() << ":" << _db.lastError().text(); _db.setDatabaseName(QDir::toNativeSeparators(getTempDbPathFor(_slug))); if (!_db.open()) { qWarning() << "Could not open temp database file" << _db.databaseName() << ":" << _db.lastError().text(); return; // Give up } } if (!checkCompatibleDb()) { qDebug() << "Database version incompatible, reinitializing"; eraseDb(); initializeDb(); } fetchConfigIfOutdated(); if (!username.isEmpty()) { login(username, password); } fetchForumsIfOutdated(); initializeBbCode(); // TODO This might depend on board config initializeSmilies(); } Board::~Board() { disconnect(this, SLOT(handleActionFinished(Action*))); if (!_slug.isEmpty()) { qDebug() << "Cleaning cache database"; cleanDb(); QSqlDatabase::removeDatabase(_slug); } } void Board::enqueueAction(Action *action) { // Let's find if a duplicate action is in there already. qDebug() << "Enqueing" << action; foreach (Action *a, _queue) { if (a->isSupersetOf(action)) { qDebug() << "Action superseded, ignoring"; delete action; return; } } // Otherwise, enqueue the action connect(action, SIGNAL(finished(Action*)), SLOT(handleActionFinished(Action*))); connect(action, SIGNAL(error(Action*,QString)), SLOT(handleActionError(Action*,QString))); _queue.enqueue(action); if (_queue.size() == 1) { // There were no actions queued, so start by executing this one. executeActionFromQueue(); emit busyChanged(); } } QString Board::getConfig(const QString &key) const { // Try config cache first QHash::const_iterator i = _config.find(key); if (i != _config.end()) { // Cache hit return i.value(); } // Try database QSqlQuery query(_db); query.prepare("SELECT key, value FROM config WHERE key = :key"); query.bindValue(":key", key); if (!query.exec()) { qWarning() << "Could not get configuration key:" << key; return QString(); } if (query.next()) { QString value = query.value(1).toString(); _config[key] = value; // Store in cache return value; } return QString(); } void Board::setConfig(const QString &key, const QString &value) { // Try config cache first QHash::const_iterator i = _config.find(key); if (i != _config.end()) { QString old_value = i.value(); if (old_value == value) { // It's in the cache, and it's the same value: don't change. return; } } // Update value in DB QSqlQuery query(_db); query.prepare("INSERT OR REPLACE INTO config (key, value) VALUES (:key, :value)"); query.bindValue(":key", key); query.bindValue(":value", value); if (!query.exec()) { qWarning() << "Could not set configuration key" << key << ":" << query.lastError().text(); } _config.insert(key, value); notifyConfigChanged(key); } QVariant Board::getLoginInfo(const QString &key) const { return _loginInfo[key]; } void Board::login(const QString &username, const QString &password) { enqueueAction(new LoginAction(username, password, this)); } void Board::logout() { // TODO } int Board::getTopicForumId(int topicId) { QSqlQuery q(_db); q.prepare("SELECT forum_id FROM topics WHERE topic_id = ?"); q.bindValue(0, topicId); if (q.exec()) { if (q.next()) { return q.value(0).toInt(); } else { qWarning() << "Could not get forum of topic"; } } else { qWarning() << "Could not get forum of topic:" << q.lastError().text(); } return -1; } void Board::newTopic(int forumId, const QString &subject, const QString &text) { enqueueAction(new NewTopicAction(forumId, subject, text, this)); } void Board::replyToTopic(int topicId, const QString &text) { enqueueAction(new NewPostAction(topicId, text, this)); } QString Board::removeHtml(QString text) const { static const QRegExp regexp("<[a-zA-Z\\/][^>]*>"); text.replace(regexp, ""); return text; } QString Board::removeBbcode(QString text) const { static const QRegExp regexp("\\[[a-zA-Z\\/][^]]*\\]"); text.replace(regexp, ""); return text; } QString Board::bbcodeToRichText(QString text) const { typedef QPair Pair; // Workaround for ',' in Q_FOREACH foreach (const Pair& pair, _bbcodes) { text.replace(pair.first, pair.second); } return text; } QString Board::parseSmilies(QString text) const { int pos = 0; text.append(' '); // So that the regexp matches a smilie at the end. while ((pos = _smilieRegexp.indexIn(text, pos)) != -1) { const int len = _smilieRegexp.matchedLength(); QString smilie = _smilieRegexp.cap(1); const int smilie_pos = _smilieRegexp.pos(1); const int smilie_len = smilie.length(); QHash::const_iterator i = _smilies.find(smilie); if (i != _smilies.end()) { QString replacement = ""; text.replace(smilie_pos, smilie_len, replacement); pos += replacement.size(); } else { pos += len - 1; } } text.remove(text.length() - 1, 1); return text; } QString Board::formatDateTime(const QDateTime &dateTime, DateTimePrecisionOptions precision) const { const QDateTime now = QDateTime::currentDateTime(); const QDateTime ldt = dateTime.toLocalTime(); const int secs = ldt.secsTo(now); const int days = ldt.daysTo(now); if (precision & RelativeTime) { if (secs < 1) { return tr("Just now"); } else if (secs < 60) { return tr("%n second(s) ago", 0, secs); } else if (secs < 3600) { int mins = (secs + 10) / 60; // + 10 to round a bit return tr("%n minute(s) ago", 0, mins); } // Fall through } if (precision & (TodayYesterday | RelativeDate)) { if (precision & ShowTime) { const QString time = ldt.time().toString(Qt::DefaultLocaleShortDate); if (days == 0) { return tr("Today %1").arg(time); } else if (days == 1) { return tr("Yesterday %1").arg(time); } } else { if (days == 0) { return tr("Today"); } else if (days == 1) { return tr("Yesterday"); } } } if (precision & RelativeDate) { if (days < 7) { const int dayOfWeek = ldt.date().dayOfWeek(); QString name = QDate::longDayName(dayOfWeek, QDate::StandaloneFormat); name[0] = name[0].toUpper(); return name; } else if (days < 30) { return tr("%n week(s) ago", 0, days / 7); } else if (days < 365) { return tr("%n month(s) ago", 0, days / 30); } else { return tr("%n year(s) ago", 0, days / 365); } } if (precision & ShowTime) { return ldt.toString(Qt::DefaultLocaleShortDate); } else { return ldt.date().toString(Qt::DefaultLocaleShortDate); } } void Board::cancelAllActions() { if (!_queue.isEmpty()) { disconnect(this, SLOT(handleActionFinished(Action*))); while (!_queue.isEmpty()) { Action *action = _queue.dequeue(); delete action; } emit busyChanged(); } } void Board::notifyConfigChanged(const QString& key) { if (!key.isEmpty()) { _config.remove(key); } else { // Must assume all keys were changed _config.clear(); } emit configChanged(key); } void Board::notifyForumsChanged() { emit forumsChanged(); } void Board::notifyForumChanged(int forumId) { emit forumChanged(forumId); } void Board::notifyForumTopicsChanged(int forumId, TopicType type, int start, int end) { qDebug() << "ForumTopics changed" << forumId << type << start << end; emit forumTopicsChanged(forumId, type, start, end); } void Board::notifyForumTopicChanged(int forumId, int topicId) { qDebug() << "ForumTopic changed" << forumId << topicId; emit forumTopicChanged(forumId, topicId); } void Board::notifyTopicPostsChanged(int topicId, int start, int end) { qDebug() << "TopicPosts changed" << topicId << start << end; emit topicPostsChanged(topicId, start, end); } void Board::notifyTopicPostsUnread(int topicId, int position) { qDebug() << "TopicPosts unread" << topicId << position; emit topicPostsUnread(topicId, position); } void Board::notifyLogin(const QMap &info) { if (_loginInfo.empty()) { _loginInfo = info; qDebug() << "Now logged in as" << _loginInfo["username"].toString(); emit loggedInChanged(); if (getConfig("last_forums_fetch_logged_in") != "1") { // Forum list is probably outdated, because we fetched it while // we were logged out. if (_iface->isAccessible()) { qDebug() << "Fetching forums because of login"; enqueueAction(new FetchForumsAction(this)); } } } else { // Double login? _loginInfo = info; } } void Board::notifyLogout() { if (!_loginInfo.empty()) { _loginInfo.clear(); emit loggedInChanged(); } } void Board::markTopicAsRead(int topicId) { QSqlQuery q(_db); q.prepare("UPDATE topics SET new_post = 0 WHERE topic_id = ? AND new_post = 1"); q.bindValue(0, topicId); if (q.exec()) { if (q.numRowsAffected() > 0) { int forum_id = getTopicForumId(topicId); if (forum_id >= 0) { notifyForumTopicChanged(forum_id, topicId); updateForumReadState(forum_id); } } } else { qWarning() << "Could not mark topic as read:" << q.lastError().text(); } } QString Board::createSlug(const QUrl& url) { static const QRegExp regexp("[^a-z0-9]+"); static const QString mobiquo_default_endpoint("/mobiquo.php"); QString slug = url.toString(QUrl::RemoveScheme | QUrl::RemoveUserInfo | QUrl::StripTrailingSlash); slug = slug.toLower(); if (slug.startsWith("//")) { slug.remove(0, 2); } if (slug.endsWith(mobiquo_default_endpoint)) { slug.chop(mobiquo_default_endpoint.size()); } slug.replace(regexp, "_"); return slug; } QString Board::getDbPathFor(const QString &slug) { return board_manager->getCachePath() + "/" + slug + ".sqlite"; } QString Board::getTempDbPathFor(const QString& slug) { return QDir::tempPath() + "/" + slug + ".sqlite"; } int Board::dbSize() const { QFileInfo info(getDbPathFor(_slug)); return info.size(); } bool Board::checkCompatibleDb() { QString version = getConfig("tapasboard_db_version"); return version == CURRENT_DB_VERSION; } bool Board::initializeDb() { QSqlQuery q(_db); if (!q.exec("PRAGMA synchronous = OFF")) { qWarning() << "Could not disable synchronous flag:" << q.lastError().text(); // Not fatal } if (!q.exec("CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)")) { qWarning() << "Could not create config table:" << q.lastError().text(); return false; } if (!q.exec("CREATE TABLE IF NOT EXISTS forums (forum_id INTEGER PRIMARY KEY, forum_name TEXT, description TEXT, parent_id INTEGER, logo_url TEXT, new_post BOOL, is_protected BOOL, is_subscribed BOOL, can_subscribe BOOL, url TEXT, sub_only BOOL, position INT)")) { qWarning() << "Could not create forums table:" << q.lastError().text(); return false; } if (!q.exec("CREATE INDEX IF NOT EXISTS forums_parent ON forums (parent_id)")) { qWarning() << "Could not create forums_parent index:" << q.lastError().text(); return false; } if (!q.exec("CREATE UNIQUE INDEX IF NOT EXISTS forums_order ON forums (position ASC)")) { qWarning() << "Could not create forums_order index:" << q.lastError().text(); return false; } if (!q.exec("CREATE TABLE IF NOT EXISTS topics (forum_id INTEGER, topic_id INTEGER PRIMARY KEY, topic_type INT, topic_title TEXT, topic_author_id INTEGER, topic_author_name TEXT, is_subscribed BOOL, is_closed BOOL, icon_url TEXT, last_reply_time TEXT, reply_number INT, new_post BOOL, position INT, last_update_time TEXT)")) { qWarning() << "Could not create topics table:" << q.lastError().text(); return false; } if (!q.exec("CREATE INDEX IF NOT EXISTS topics_forum ON topics (forum_id)")) { qWarning() << "Could not create topics_forum index:" << q.lastError().text(); return false; } if (!q.exec("CREATE INDEX IF NOT EXISTS topics_time ON topics (last_reply_time)")) { qWarning() << "Could not create topics_time index:" << q.lastError().text(); return false; } if (!q.exec("CREATE INDEX IF NOT EXISTS topics_order ON topics (forum_id, topic_type DESC, position ASC)")) { qWarning() << "Could not create topics_order index:" << q.lastError().text(); return false; } if (!q.exec("CREATE TABLE IF NOT EXISTS posts (forum_id INTEGER, topic_id INTEGER, post_id INTEGER PRIMARY KEY, post_title TEXT, post_content TEXT, post_author_id INTEGER, post_author_name TEXT, can_edit BOOL, icon_url TEXT, post_time TEXT, position INT, last_update_time TEXT)")) { qWarning() << "Could not create posts table:" << q.lastError().text(); return false; } if (!q.exec("CREATE INDEX IF NOT EXISTS posts_topic ON posts (topic_id)")) { qWarning() << "Could not create posts_topic index:" << q.lastError().text(); return false; } if (!q.exec("CREATE INDEX IF NOT EXISTS posts_time ON posts (post_time)")) { qWarning() << "Could not create posts_time index:" << q.lastError().text(); return false; } if (!q.exec("CREATE INDEX IF NOT EXISTS posts_order ON posts (topic_id, position ASC)")) { qWarning() << "Could not create posts_order index:" << q.lastError().text(); return false; } return true; } bool Board::eraseDb() { QSqlQuery q(_db); if (!q.exec("DROP TABLE IF EXISTS config")) { qWarning() << "Could not drop config table:" << q.lastError().text(); return false; } if (!q.exec("DROP TABLE IF EXISTS forums")) { qWarning() << "Could not drop forums table:" << q.lastError().text(); return false; } if (!q.exec("DROP TABLE IF EXISTS topics")) { qWarning() << "Could not drop topics table:" << q.lastError().text(); return false; } if (!q.exec("DROP TABLE IF EXISTS posts")) { qWarning() << "Could not drop posts table:" << q.lastError().text(); return false; } if (!q.exec("VACUUM")) { qWarning() << "Could not vacuum database:" << q.lastError().text(); return false; } return true; } bool Board::cleanDb() { QSqlQuery q(_db); int total_rows = 0; q.prepare("DELETE FROM topics WHERE last_update_time < ?"); q.bindValue(0, QDateTime::currentDateTimeUtc().addDays(-FORUM_TOPICS_CACHE)); if (q.exec()) { total_rows += q.numRowsAffected(); } else { qWarning() << "Could not clean old topics:" << q.lastError().text(); } q.prepare("DELETE FROM posts WHERE last_update_time < ?"); q.bindValue(0, QDateTime::currentDateTimeUtc().addDays(-TOPIC_POSTS_CACHE)); if (q.exec()) { total_rows += q.numRowsAffected(); } else { qWarning() << "Could not clean old posts:" << q.lastError().text(); } if (q.exec("DELETE FROM topics WHERE forum_id NOT IN (SELECT forum_id FROM forums)")) { total_rows += q.numRowsAffected(); } else { qWarning() << "Could not clean unreferenced topics:" << q.lastError().text(); } if (q.exec("DELETE FROM posts WHERE topic_id NOT IN (SELECT topic_id FROM topics)")) { total_rows += q.numRowsAffected(); } else { qWarning() << "Could not clean unreferenced posts:" << q.lastError().text(); } if (total_rows > 100) { qDebug() << "Vacuuming database"; if (q.exec("VACUUM")) { qWarning() << "Could not vacuum database:" << q.lastError().text(); } } return true; } bool Board::removeFromActionQueue(Action *action) { if (_queue.isEmpty()) return false; Action *head = _queue.head(); if (_queue.removeOne(action)) { if (!_queue.isEmpty() && head != _queue.head()) { // The head action was removed; advance the queue. qDebug() << "Now running" << _queue.head(); executeActionFromQueue(); } action->deleteLater(); // Don't use delete here because this might be called in response to // error() or finished() QNetworkReply signals. if (_queue.isEmpty()) { emit busyChanged(); } return true; } return false; } void Board::executeActionFromQueue() { if (!_queue.empty()) { Action *head = _queue.head(); head->execute(); } } void Board::initializeBbCode() { _bbcodes.clear(); _bbcodes << qMakePair(QRegExp("\\[(/?[bius])\\]", Qt::CaseInsensitive), QString("<\\1>")); _bbcodes << qMakePair(QRegExp("\\[(/?)quote\\]", Qt::CaseInsensitive), QString("<\\1blockquote>")); _bbcodes << qMakePair(QRegExp("\\[url\\]([^[]*)\\[/url\\]", Qt::CaseInsensitive), QString("\\1")); _bbcodes << qMakePair(QRegExp("\\[url=([^]]*)\\]", Qt::CaseInsensitive), QString("")); _bbcodes << qMakePair(QRegExp("\\[/url\\]", Qt::CaseInsensitive), QString("")); _bbcodes << qMakePair(QRegExp("\\[img\\]([^[]*)\\[/img\\]", Qt::CaseInsensitive), QString("")); _bbcodes << qMakePair(QRegExp("\\[hr\\]", Qt::CaseInsensitive), QString("
")); _bbcodes << qMakePair(QRegExp("\n"), QString("
")); } void Board::initializeSmilies() { _smilies[":)"] = "image://theme/icon-s-messaging-smiley-happy"; _smilies["=)"] = "image://theme/icon-s-messaging-smiley-happy"; _smilies[":("] = "image://theme/icon-s-messaging-smiley-sad"; _smilies[":D"] = "image://theme/icon-s-messaging-smiley-very-happy"; _smilies["XD"] = "image://theme/icon-s-messaging-smiley-very-happy"; _smilies["=D>"] = "image://theme/icon-s-messaging-smiley-very-happy"; _smilies[":lol:"] = "image://theme/icon-s-messaging-smiley-very-happy"; _smilies[";)"] = "image://theme/icon-s-messaging-smiley-wink"; _smilies[";D"] = "image://theme/icon-s-messaging-smiley-wink"; _smilies[":wink:"] = "image://theme/icon-s-messaging-smiley-wink"; _smilies[":P"] = "image://theme/icon-s-messaging-smiley-tongue"; _smilies[":p"] = "image://theme/icon-s-messaging-smiley-tongue"; _smilies[":mrgreen:"] = "image://theme/icon-s-messaging-smiley-grin"; _smilies[":roll:"] = "image://theme/icon-s-messaging-smiley-grin"; _smilies[":O"] = "image://theme/icon-s-messaging-smiley-surprised"; _smilies["O_O"] = "image://theme/icon-s-messaging-smiley-surprised"; _smilies["O_o"] = "image://theme/icon-s-messaging-smiley-surprised"; _smilies[":shock:"] = "image://theme/icon-s-messaging-smiley-surprised"; _smilies[":evil:"] = "image://theme/icon-s-messaging-smiley-evil"; // Let's construct a single regular expression that will cover all smilies QString regexp("[^A-Za-z]("); for (QHash::const_iterator i = _smilies.begin(); i != _smilies.end(); i++) { if (i != _smilies.begin()) { regexp += "|"; } regexp += QRegExp::escape(i.key()); } regexp += ")[^A-Za-z]"; _smilieRegexp = QRegExp(regexp); Q_ASSERT(_smilieRegexp.isValid()); } void Board::fetchConfigIfOutdated() { if (_iface->isAccessible()) { // Only fetch if network is accessible and data is >48h old. QDateTime last_fetch = QDateTime::fromString( getConfig("last_config_fetch"), Qt::ISODate); if (!last_fetch.isValid() || last_fetch.daysTo(QDateTime::currentDateTimeUtc()) >= BOARD_CONFIG_TTL) { enqueueAction(new FetchConfigAction(this)); } } } void Board::fetchForumsIfOutdated() { if (_iface->isAccessible()) { // Only fetch if network is accessible and data is >48h old. QDateTime last_fetch = QDateTime::fromString( getConfig("last_forums_fetch"), Qt::ISODate); if (!last_fetch.isValid() || last_fetch.daysTo(QDateTime::currentDateTimeUtc()) >= BOARD_LIST_TTL) { enqueueAction(new FetchForumsAction(this)); } } } void Board::updateForumReadState(int forumId) { QSqlQuery q(_db); q.prepare("SELECT COUNT() FROM topics WHERE forum_id = ? AND new_post = 1"); q.bindValue(0, forumId); if (!q.exec()) { qWarning() << "Could not select unread topics from forum:" << q.lastError().text(); return; } int unread_topics = 0; if (q.next()) { unread_topics = q.value(0).toInt(); } int new_post = (unread_topics > 0) ? 1 : 0; q.prepare("UPDATE forums SET new_post = :new_post WHERE forum_id = :forum_id AND new_post != :cur_new_post"); q.bindValue(":new_post", new_post); q.bindValue(":forum_id", forumId); q.bindValue(":cur_new_post", new_post); if (!q.exec()) { qWarning() << "Could not update forum read status:" << q.lastError().text(); return; } if (q.numRowsAffected() > 0) { notifyForumChanged(forumId); } } void Board::handleActionFinished(Action *action) { qDebug() << action << "finished"; bool ok = removeFromActionQueue(action); if (!ok) { qWarning() << "Finished action not in queue"; } } void Board::handleActionError(Action *action, const QString& message) { qWarning() << "Action failed:" << message; removeFromActionQueue(action); }