diff options
-rw-r--r-- | action.h | 2 | ||||
-rw-r--r-- | board.cpp | 81 | ||||
-rw-r--r-- | board.h | 5 | ||||
-rw-r--r-- | boardmodel.cpp | 2 | ||||
-rw-r--r-- | fetchboardconfigaction.cpp | 6 | ||||
-rw-r--r-- | fetchboardconfigaction.h | 2 | ||||
-rw-r--r-- | fetchforumsaction.cpp | 6 | ||||
-rw-r--r-- | fetchforumsaction.h | 2 | ||||
-rw-r--r-- | fetchpostsaction.cpp | 98 | ||||
-rw-r--r-- | fetchpostsaction.h | 33 | ||||
-rw-r--r-- | fetchtopicsaction.cpp | 23 | ||||
-rw-r--r-- | fetchtopicsaction.h | 3 | ||||
-rw-r--r-- | forummodel.cpp | 9 | ||||
-rw-r--r-- | global.h | 12 | ||||
-rw-r--r-- | main.cpp | 2 | ||||
-rw-r--r-- | qml/tapasboard/BoardPage.qml | 34 | ||||
-rw-r--r-- | qml/tapasboard/EmptyListDelegate.qml | 29 | ||||
-rw-r--r-- | qml/tapasboard/ForumPage.qml | 28 | ||||
-rw-r--r-- | qml/tapasboard/TopicPage.qml | 63 | ||||
-rw-r--r-- | tapasboard.pro | 8 | ||||
-rw-r--r-- | topicmodel.cpp | 285 | ||||
-rw-r--r-- | topicmodel.h | 72 | ||||
-rw-r--r-- | xmlrpcinterface.cpp | 3 | ||||
-rw-r--r-- | xmlrpcinterface.h | 2 | ||||
-rw-r--r-- | xmlrpcpendingcall.cpp | 3 |
25 files changed, 793 insertions, 20 deletions
@@ -13,6 +13,8 @@ class Action : public QObject public: explicit Action(Board *board); + virtual bool isSupersetOf(Action *action) const = 0; + signals: void finished(Action *self); void error(Action *self, const QString& messsage); @@ -28,8 +28,25 @@ Board::Board(const QString& forumUrl, QObject *parent) : fetchForumsIfOutdated(); } +Board::~Board() +{ + 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))); @@ -95,6 +112,12 @@ void Board::notifyForumTopicsChanged(int forumId, int start, int end) emit forumTopicsChanged(forumId, start, end); } +void Board::notifyTopicPostsChanged(int topicId, int start, int end) +{ + qDebug() << "TopicPosts Changed" << topicId << start << end; + emit topicPostsChanged(topicId, start, end); +} + QString Board::createSlug(const QString &forumUrl) { static const QRegExp regexp("[^a-z0-9]+"); @@ -157,6 +180,58 @@ bool Board::initializeDb() 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, 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; + } + + 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); + // TODO: Delete old posts from cache + if (!q.exec("VACUUM")) { + qWarning() << "Could not vacuum database:" << q.lastError().text(); + return false; + } return true; } @@ -210,7 +285,11 @@ void Board::fetchForumsIfOutdated() void Board::handleActionFinished(Action *action) { - removeFromActionQueue(action); + qDebug() << action << "finished"; + bool ok = removeFromActionQueue(action); + if (!ok) { + qWarning() << "Finished action not in queue"; + } } void Board::handleActionError(Action *action, const QString& message) @@ -13,6 +13,7 @@ class Board : public QObject Q_OBJECT public: explicit Board(const QString& boardUrl, QObject *parent = 0); + ~Board(); bool busy() const; void enqueueAction(Action* action); @@ -28,18 +29,22 @@ public: void notifyConfigChanged(); void notifyForumsChanged(); void notifyForumTopicsChanged(int forumId, int start, int end); + void notifyTopicPostsChanged(int topicId, int start, int end); signals: void configChanged(); void forumsChanged(); void imageChanged(const QString& imageUrl); void forumTopicsChanged(int forumId, int start, int end); + void topicPostsChanged(int topicId, int start, int end); private: static QString createSlug(const QString& forumUrl); static QString getDbDir(); static QString getDbPathFor(const QString& slug); bool initializeDb(); + bool eraseDb(); + bool cleanDb(); bool removeFromActionQueue(Action *action); void executeActionFromQueue(); void fetchConfigIfOutdated(); diff --git a/boardmodel.cpp b/boardmodel.cpp index 77778ad..f8da74e 100644 --- a/boardmodel.cpp +++ b/boardmodel.cpp @@ -12,7 +12,7 @@ BoardModel::BoardModel(QObject *parent) : QHash<int, QByteArray> roles = roleNames(); roles[NameRole] = QByteArray("title"); roles[LogoRole] = QByteArray("logo"); - roles[DescriptionRole] = QByteArray("subtitle"); + roles[DescriptionRole] = QByteArray("description"); roles[ForumIdRole] = QByteArray("forumId"); roles[ParentIdRole] = QByteArray("parentId"); roles[CategoryRole] = QByteArray("category"); diff --git a/fetchboardconfigaction.cpp b/fetchboardconfigaction.cpp index 4d1cea2..0dd5e4c 100644 --- a/fetchboardconfigaction.cpp +++ b/fetchboardconfigaction.cpp @@ -12,6 +12,12 @@ FetchBoardConfigAction::FetchBoardConfigAction(Board *board) : { } +bool FetchBoardConfigAction::isSupersetOf(Action *action) const +{ + // If 'action' is also a fetch board config action then yes, this supersets 'action'. + return qobject_cast<FetchBoardConfigAction*>(action) != 0; +} + void FetchBoardConfigAction::execute() { _call = _board->service()->asyncCall("get_config"); diff --git a/fetchboardconfigaction.h b/fetchboardconfigaction.h index 05bd65f..7f350f9 100644 --- a/fetchboardconfigaction.h +++ b/fetchboardconfigaction.h @@ -11,6 +11,8 @@ class FetchBoardConfigAction : public Action public: explicit FetchBoardConfigAction(Board *board); + bool isSupersetOf(Action *action) const; + void execute(); private slots: diff --git a/fetchforumsaction.cpp b/fetchforumsaction.cpp index 8b3e4c8..7b496c3 100644 --- a/fetchforumsaction.cpp +++ b/fetchforumsaction.cpp @@ -13,6 +13,12 @@ FetchForumsAction::FetchForumsAction(Board *board) : { } +bool FetchForumsAction::isSupersetOf(Action *action) const +{ + // If 'action' is also a fetch forums list action then yes, this supersets 'action'. + return qobject_cast<FetchForumsAction*>(action) != 0; +} + void FetchForumsAction::execute() { _call = _board->service()->asyncCall("get_forum"); diff --git a/fetchforumsaction.h b/fetchforumsaction.h index b8625fa..b047bcb 100644 --- a/fetchforumsaction.h +++ b/fetchforumsaction.h @@ -12,6 +12,8 @@ class FetchForumsAction : public Action public: explicit FetchForumsAction(Board *board); + bool isSupersetOf(Action *action) const; + void execute(); private slots: diff --git a/fetchpostsaction.cpp b/fetchpostsaction.cpp new file mode 100644 index 0000000..16802fa --- /dev/null +++ b/fetchpostsaction.cpp @@ -0,0 +1,98 @@ +#include <QtCore/QDateTime> +#include <QtCore/QDebug> +#include <QtSql/QSqlDatabase> +#include <QtSql/QSqlQuery> + +#include "board.h" +#include "xmlrpcinterface.h" +#include "xmlrpcreply.h" +#include "fetchpostsaction.h" + +FetchPostsAction::FetchPostsAction(int topicId, int start, int end, Board *board) : + Action(board), _topicId(topicId), _start(start), _end(end) +{ +} + +bool FetchPostsAction::isSupersetOf(Action *action) const +{ + FetchPostsAction *other = qobject_cast<FetchPostsAction*>(action); + if (other) { + if (other->_topicId == _topicId) { + if (_start <= other->_start && _end >= other->_end) { + return true; + } + } + } + return false; +} + +void FetchPostsAction::execute() +{ + _call = _board->service()->asyncCall("get_thread", + QString::number(_topicId), _start, _end); + connect(_call, SIGNAL(finished(XmlRpcPendingCall*)), SLOT(handleFinishedCall())); +} + +void FetchPostsAction::handleFinishedCall() +{ + XmlRpcReply<QVariantMap> result(_call); + if (result.isValid()) { + QVariantMap map = result; + QVariantList posts = map["posts"].toList(); + QSqlDatabase db = _board->database(); + db.transaction(); + + QSqlQuery query(db); + query.prepare("INSERT OR REPLACE INTO posts (topic_id, post_id, post_title, post_content, post_author_id, post_author_name, can_edit, icon_url, post_time, last_update_time) " + "VALUES (:topic_id, :post_id, :post_title, :post_content, :post_author_id, :post_author_name, :can_edit, :icon_url, :post_time, :last_update_time)"); + + foreach (const QVariant& post_v, posts) { + QVariantMap post = post_v.toMap(); + bool ok = false; + int topic_id = post["topic_id"].toInt(&ok); + if (!ok) { + // Not fatal, just assume it's the one we requested + topic_id = _topicId; + } + int post_id = post["post_id"].toInt(&ok); + if (!ok) { + qWarning() << "No post_id in" << post; + continue; + } + + query.bindValue(":topic_id", topic_id); + query.bindValue(":post_id", post_id); + query.bindValue(":post_title", unencodePostText(post["post_title"])); + query.bindValue(":post_content", unencodePostText(post["post_content"])); + query.bindValue(":post_author_id", post["post_author_id"].toInt()); + query.bindValue(":post_author_name", unencodePostText(post["post_author_name"])); + query.bindValue(":can_edit", post["can_edit"].toBool() ? 1 : 0); + query.bindValue(":icon_url", post["icon_url"].toString()); + query.bindValue(":post_time", post["post_time"].toDateTime()); + query.bindValue(":last_update_time", QDateTime::currentDateTime()); + + if (!query.exec()) { + qWarning() << "Failed to store topic info for:" << topic_id; + handleDatabaseError("storing topic info", query); + continue; + } + } + + db.commit(); + if (posts.size() > 0) { + _board->notifyTopicPostsChanged(_topicId, + _start, _start + posts.size() - 1); + } + } else { + qWarning() << "Could not fetch posts"; + // TODO emit error ... + } + emit finished(this); + _call->deleteLater(); +} + +QString FetchPostsAction::unencodePostText(const QVariant &v) +{ + QByteArray ba = v.toByteArray(); + return QString::fromUtf8(ba.constData(), ba.length()); +} diff --git a/fetchpostsaction.h b/fetchpostsaction.h new file mode 100644 index 0000000..63cb5f8 --- /dev/null +++ b/fetchpostsaction.h @@ -0,0 +1,33 @@ +#ifndef FETCHPOSTSACTION_H +#define FETCHPOSTSACTION_H + +#include <QtCore/QVariant> +#include <QtSql/QSqlQuery> +#include "action.h" + +class XmlRpcPendingCall; + +class FetchPostsAction : public Action +{ + Q_OBJECT +public: + explicit FetchPostsAction(int topicId, int start, int end, Board *board); + + bool isSupersetOf(Action *action) const; + + void execute(); + +private slots: + void handleFinishedCall(); + +private: + static QString unencodePostText(const QVariant& v); + +private: + XmlRpcPendingCall *_call; + int _topicId; + int _start; + int _end; +}; + +#endif // FETCHPOSTSACTION_H diff --git a/fetchtopicsaction.cpp b/fetchtopicsaction.cpp index 5661da8..dd59b4d 100644 --- a/fetchtopicsaction.cpp +++ b/fetchtopicsaction.cpp @@ -13,6 +13,19 @@ FetchTopicsAction::FetchTopicsAction(int forumId, int start, int end, Board *boa { } +bool FetchTopicsAction::isSupersetOf(Action *action) const +{ + FetchTopicsAction *other = qobject_cast<FetchTopicsAction*>(action); + if (other) { + if (other->_forumId == _forumId) { + if (_start <= other->_start && _end >= other->_end) { + return true; + } + } + } + return false; +} + void FetchTopicsAction::execute() { _call = _board->service()->asyncCall("get_topic", @@ -20,8 +33,6 @@ void FetchTopicsAction::execute() connect(_call, SIGNAL(finished(XmlRpcPendingCall*)), SLOT(handleFinishedCall())); } -// forum_id INTEGER, topic_id INTEGER PRIMARY KEY, topic_title TEXT, topic_author_id INTEGER, topic_author_name TEXT, is_subscribed BOOL, is_closed BOOL, icon_url TEXT, last_reply_time TEXT, new_post BOOL - void FetchTopicsAction::handleFinishedCall() { XmlRpcReply<QVariantMap> result(_call); @@ -40,12 +51,12 @@ void FetchTopicsAction::handleFinishedCall() bool ok = false; int forum_id = topic["forum_id"].toInt(&ok); if (!ok) { - qWarning() << "No forum_id in" << topic; - continue; + // Not fatal, just use the one we expected. + forum_id = _forumId; } int topic_id = topic["topic_id"].toInt(&ok); if (!ok) { - qWarning() << "No parent_id in" << topic; + qWarning() << "No topic_id in" << topic; continue; } @@ -72,6 +83,8 @@ void FetchTopicsAction::handleFinishedCall() if (topics.size() > 0) { _board->notifyForumTopicsChanged(_forumId, _start, _start + topics.size() - 1); + } else { + qWarning() << "Asked for topics, got none..."; } } else { qWarning() << "Could not fetch topics"; diff --git a/fetchtopicsaction.h b/fetchtopicsaction.h index 747ad93..0b02fa3 100644 --- a/fetchtopicsaction.h +++ b/fetchtopicsaction.h @@ -12,6 +12,8 @@ class FetchTopicsAction : public Action Q_OBJECT public: explicit FetchTopicsAction(int forumId, int start, int end, Board *board); + + bool isSupersetOf(Action *action) const; void execute(); @@ -26,7 +28,6 @@ private: int _forumId; int _start; int _end; - }; #endif // FETCHTOPICSACTION_H diff --git a/forummodel.cpp b/forummodel.cpp index 43cb0bd..c66d3a8 100644 --- a/forummodel.cpp +++ b/forummodel.cpp @@ -13,10 +13,7 @@ ForumModel::ForumModel(QObject *parent) : QHash<int, QByteArray> roles = roleNames(); roles[TitleRole] = QByteArray("title"); roles[IconRole] = QByteArray("icon"); - //roles[DescriptionRole] = QByteArray("subtitle"); - //roles[ForumIdRole] = QByteArray("forumId"); - //roles[ParentIdRole] = QByteArray("parentId"); - //roles[CategoryRole] = QByteArray("category"); + roles[TopicIdRole] = QByteArray("topicId"); setRoleNames(roles); } @@ -84,6 +81,9 @@ QVariant ForumModel::data(const QModelIndex &index, int role) const case TitleRole: return _data[row].title; break; + case TopicIdRole: + return _data[row].topic_id; + break; } return QVariant(); @@ -266,6 +266,7 @@ void ForumModel::update() if (!last.isValid() || last.secsTo(QDateTime::currentDateTime()) > FORUM_TOP_TLL) { // Outdated or empty, refresh. + qDebug() << "Fetching topics because the top are old"; _board->enqueueAction(new FetchTopicsAction(_forumId, 0, FORUM_PAGE_SIZE - 1, _board)); } else { qDebug() << "Topics not outdated"; @@ -15,9 +15,19 @@ /** Time we should consider other topics in a forum up to date, in seconds. */ #define FORUM_TOPICS_TLL 15 * 60 -/** Number of topics per "block" in the forum view */ +/** Number of topics per "block" in subforum view */ #define FORUM_PAGE_SIZE 20 +/** Time we should consider the most recent posts in a topic up to date, in seconds. */ +#define TOPIC_TOP_TLL 5 * 60 + +/** Time we should consider other posts in a topic up to date, in seconds. */ +#define TOPIC_POSTS_TLL 15 * 60 + +/** Number of posts per "block" in topic view */ +#define TOPIC_PAGE_SIZE 20 + + extern BoardManager *board_manager; #endif // GLOBAL_H @@ -6,6 +6,7 @@ #include "board.h" #include "boardmodel.h" #include "forummodel.h" +#include "topicmodel.h" #include "xmlrpcinterface.h" #include "xmlrpcreply.h" @@ -28,6 +29,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) qmlRegisterType<BoardModel>("com.javispedro.tapasboard", 1, 0, "BoardModel"); qmlRegisterType<ForumModel>("com.javispedro.tapasboard", 1, 0, "ForumModel"); + qmlRegisterType<TopicModel>("com.javispedro.tapasboard", 1, 0, "TopicModel"); viewer.setOrientation(QmlApplicationViewer::ScreenOrientationAuto); viewer.setMainQmlFile(QLatin1String("qml/tapasboard/main.qml")); diff --git a/qml/tapasboard/BoardPage.qml b/qml/tapasboard/BoardPage.qml index 7c3f1cd..57d362d 100644 --- a/qml/tapasboard/BoardPage.qml +++ b/qml/tapasboard/BoardPage.qml @@ -35,8 +35,34 @@ Page { text: section } - delegate: ListDelegate { + delegate: EmptyListDelegate { + id: forumItem + + height: Math.max(forumItemColumn.height + UiConstants.ButtonSpacing * 2, UiConstants.ListItemHeightDefault) + + Column { + id: forumItemColumn + anchors.left: parent.left + anchors.right: forumItemImage.left + anchors.verticalCenter: parent.verticalCenter + + Text { + text: model.title + width: parent.width + font: UiConstants.TitleFont + } + + Text { + text: model.description + width: parent.width + font: UiConstants.SubtitleFont + wrapMode: Text.Wrap + visible: text != "" + } + } + Image { + id: forumItemImage source: "image://theme/icon-m-common-drilldown-arrow" + (theme.inverted ? "-inverse" : "") anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -44,9 +70,9 @@ Page { onClicked: { pageStack.push(Qt.resolvedUrl("ForumPage.qml"), { - boardUrl: boardPage.boardUrl, - forumId: model.forumId - }); + boardUrl: boardPage.boardUrl, + forumId: model.forumId + }); } } } diff --git a/qml/tapasboard/EmptyListDelegate.qml b/qml/tapasboard/EmptyListDelegate.qml new file mode 100644 index 0000000..9a9d63d --- /dev/null +++ b/qml/tapasboard/EmptyListDelegate.qml @@ -0,0 +1,29 @@ +import QtQuick 1.1 +import com.nokia.meego 1.1 + +Item { + id: listItem + + signal clicked + property alias pressed: mouseArea.pressed + + height: UiConstants.ListItemHeightDefault + width: parent.width + + BorderImage { + id: background + anchors.fill: parent + anchors.leftMargin: -UiConstants.DefaultMargin + anchors.rightMargin: -UiConstants.DefaultMargin + visible: pressed + source: theme.inverted ? "image://theme/meegotouch-panel-inverted-background-pressed" : "image://theme/meegotouch-panel-background-pressed" + } + + MouseArea { + id: mouseArea; + anchors.fill: parent + onClicked: { + listItem.clicked(); + } + } +} diff --git a/qml/tapasboard/ForumPage.qml b/qml/tapasboard/ForumPage.qml index a70fe74..6e39fe2 100644 --- a/qml/tapasboard/ForumPage.qml +++ b/qml/tapasboard/ForumPage.qml @@ -28,12 +28,38 @@ Page { boardUrl: forumPage.boardUrl forumId: forumPage.forumId } - delegate: ListDelegate { + delegate: EmptyListDelegate { + id: topicItem + + height: Math.max(topicItemColumn.height + UiConstants.ButtonSpacing * 2, UiConstants.ListItemHeightDefault) + + Column { + id: topicItemColumn + anchors.left: parent.left + anchors.right: topicItemImage.left + anchors.verticalCenter: parent.verticalCenter + + Text { + text: model.title + width: parent.width + font: UiConstants.TitleFont + wrapMode: Text.Wrap + } + } + Image { + id: topicItemImage source: "image://theme/icon-m-common-drilldown-arrow" + (theme.inverted ? "-inverse" : "") anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter } + + onClicked: { + pageStack.push(Qt.resolvedUrl("TopicPage.qml"), { + boardUrl: forumPage.boardUrl, + topicId: model.topicId + }); + } } } diff --git a/qml/tapasboard/TopicPage.qml b/qml/tapasboard/TopicPage.qml new file mode 100644 index 0000000..d39d36e --- /dev/null +++ b/qml/tapasboard/TopicPage.qml @@ -0,0 +1,63 @@ +import QtQuick 1.1 +import com.nokia.meego 1.1 +import com.nokia.extras 1.1 +import com.javispedro.tapasboard 1.0 + +Page { + id: topicPage + + anchors.leftMargin: UiConstants.DefaultMargin + anchors.rightMargin: UiConstants.DefaultMargin + + property string boardUrl; + property int topicId; + + tools: ToolBarLayout { + ToolIcon { + id: backToolIcon + platformIconId: "toolbar-back" + anchors.left: parent.left + onClicked: pageStack.pop() + } + } + + ListView { + id: postsView + anchors.fill: parent + model: TopicModel { + boardUrl: topicPage.boardUrl + topicId: topicPage.topicId + } + delegate: Item { + id: postItem + + height: Math.max(postItemColumn.height + UiConstants.ButtonSpacing * 2, UiConstants.ListItemHeightDefault) + width: parent.width + + Column { + id: postItemColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + Text { + text: model.title + width: parent.width + font: UiConstants.TitleFont + visible: text != "" + } + + Text { + text: model.content + width: parent.width + font: UiConstants.SubtitleFont + wrapMode: Text.Wrap + } + } + } + } + + ScrollDecorator { + flickableItem: postsView + } +} diff --git a/tapasboard.pro b/tapasboard.pro index a3ab2f5..1ecefde 100644 --- a/tapasboard.pro +++ b/tapasboard.pro @@ -42,7 +42,9 @@ SOURCES += main.cpp \ fetchforumsaction.cpp \ boardmodel.cpp \ forummodel.cpp \ - fetchtopicsaction.cpp + fetchtopicsaction.cpp \ + topicmodel.cpp \ + fetchpostsaction.cpp # Please do not modify the following two lines. Required for deployment. include(qmlapplicationviewer/qmlapplicationviewer.pri) @@ -69,4 +71,6 @@ HEADERS += \ global.h \ boardmodel.h \ forummodel.h \ - fetchtopicsaction.h + fetchtopicsaction.h \ + topicmodel.h \ + fetchpostsaction.h diff --git a/topicmodel.cpp b/topicmodel.cpp new file mode 100644 index 0000000..bbc2314 --- /dev/null +++ b/topicmodel.cpp @@ -0,0 +1,285 @@ +#include <QtCore/QDebug> +#include <QtSql/QSqlError> + +#include "global.h" +#include "board.h" +#include "xmlrpcinterface.h" +#include "fetchpostsaction.h" +#include "topicmodel.h" + +TopicModel::TopicModel(QObject *parent) : + QAbstractListModel(parent), _boardUrl(), _board(0), _topicId(-1) +{ + QHash<int, QByteArray> roles = roleNames(); + roles[TitleRole] = QByteArray("title"); + roles[ContentRole] = QByteArray("content"); + roles[IconRole] = QByteArray("icon"); + roles[PostIdRole] = QByteArray("postcId"); + setRoleNames(roles); +} + +QString TopicModel::boardUrl() const +{ + return _boardUrl; +} + +void TopicModel::setBoardUrl(const QString &url) +{ + if (_boardUrl != url) { + disconnect(this, SLOT(handleTopicPostsChanged(int,int,int))); + clearModel(); + _board = 0; + + _boardUrl = url; + if (!_boardUrl.isEmpty()) { + _board = board_manager->getBoard(_boardUrl); + connect(_board, SIGNAL(topicPostsChanged(int,int,int)), + SLOT(handleTopicPostsChanged(int,int,int))); + if (_topicId >= 0) { + update(); + reload(); + } + } + emit boardUrlChanged(); + } +} + +int TopicModel::topicId() const +{ + return _topicId; +} + +void TopicModel::setTopicId(const int id) +{ + if (_topicId != id) { + clearModel(); + + _topicId = id; + + if (_topicId >= 0 && _board) { + update(); + reload(); + } + emit topicIdChanged(); + } +} + +int TopicModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : _data.size(); +} + +QVariant TopicModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) return QVariant(); + const int row = index.row(); + if (row >= _data.size()) { + qWarning() << "Could not seek to" << row; + return QVariant(); + } + + switch (role) { + case TitleRole: + return _data[row].title; + break; + case ContentRole: + return _data[row].content; + break; + case PostIdRole: + return _data[row].post_id; + break; + } + + return QVariant(); +} + +bool TopicModel::canFetchMore(const QModelIndex &parent) const +{ + if (parent.isValid() || !_board) return false; // Invalid state + return !_eof; +} + +void TopicModel::fetchMore(const QModelIndex &parent) +{ + if (parent.isValid()) return; + if (!_board) return; + if (_eof) return; + + const int start = _data.size(); + QList<Post> posts = loadPosts(start, start + TOPIC_PAGE_SIZE - 1); + const int new_end = start + _data.size() - 1; + + if (posts.empty()) { + // We could not load anything more from DB! + _eof = true; + } else { + beginInsertRows(QModelIndex(), start, new_end); + _data.append(posts); + _eof = posts.size() < TOPIC_PAGE_SIZE; // If short read, we reached EOF. + endInsertRows(); + } + + if (_board->service()->isAccessible()) { + if (!_data.empty()) { + QDateTime last = oldestPostUpdate(posts); + // If the posts we got from DB are too old, refresh online. + if (last.secsTo(QDateTime::currentDateTime()) > TOPIC_POSTS_TLL) { + qDebug() << "Fetching posts because of old"; + _board->enqueueAction(new FetchPostsAction(_topicId, + start, + new_end, + _board)); + } + } + + // Try to fetch more posts if board is online and we reached the end of DB + if (_eof) { + qDebug() << "Fetching posts because of EOF"; + _board->enqueueAction(new FetchPostsAction(_topicId, + _data.size(), + _data.size() + FORUM_PAGE_SIZE - 1, + _board)); + } + } +} + +QDateTime TopicModel::parseDateTime(const QVariant &v) +{ + QString s = v.toString(); + return QDateTime::fromString(s, Qt::ISODate); +} + +QDateTime TopicModel::oldestPostUpdate(const QList<Post> &posts) +{ + if (posts.empty()) return QDateTime::currentDateTime(); + QDateTime min = posts.first().last_update_time; + foreach (const Post& post, posts) { + if (min < post.last_update_time) min = post.last_update_time; + } + return min; +} + +QDateTime TopicModel::lastTopPostUpdate() +{ + if (!_board) return QDateTime(); + QSqlDatabase db = _board->database(); + QSqlQuery query(db); + query.prepare("SELECT last_update_time FROM posts " + "WHERE topic_id = :topic_id " + "ORDER BY post_time ASC " + "LIMIT 1"); + query.bindValue(":topic_id", _topicId); + if (query.exec()) { + if (query.next()) { + return parseDateTime(query.value(0)); + } + } else { + qWarning() << "Could not fetch posts:" << query.lastError().text(); + } + return QDateTime(); +} + +QList<TopicModel::Post> TopicModel::loadPosts(int start, int end) +{ + Q_ASSERT(_board); + const int rows = end - start + 1; + QList<Post> posts; + QSqlQuery query(_board->database()); + query.prepare("SELECT post_id, post_title, post_content, post_time, last_update_time FROM posts " + "WHERE topic_id = :topic_id " + "ORDER by post_time ASC " + "LIMIT :start, :limit"); + query.bindValue(":topic_id", _topicId); + query.bindValue(":start", start); + query.bindValue(":limit", rows); + if (query.exec()) { + posts.reserve(rows); + while (query.next()) { + Post post; + post.post_id = query.value(0).toInt(); + post.title = query.value(1).toString(); + post.content = query.value(2).toString(); + post.time = parseDateTime(query.value(3)); + post.last_update_time = parseDateTime(query.value(4)); + posts.append(post); + } + } else { + qWarning() << "Could not load posts:" << query.lastError().text(); + } + return posts; +} + +void TopicModel::clearModel() +{ + beginResetModel(); + _eof = false; + _data.clear(); + endResetModel(); +} + +void TopicModel::handleTopicPostsChanged(int topicId, int start, int end) +{ + if (topicId == _topicId) { + // Yep, our posts list changed. + qDebug() << "My posts changed" << start << end; + if (end > _data.size()) { + // If for any reason we have more posts now, it means we might + // no longer be EOF... + _eof = false; + } + if (start > _data.size() + 1) { + // We are still not interested into these posts. + qDebug() << "Posts too far"; + return; + } + + QList<Post> posts = loadPosts(start, end); + if (posts.size() < end - start + 1) { + _eof = true; // Short read + end = start + posts.size() - 1; + } + + if (end >= _data.size()) { + qDebug() << "Call insert rows" << _data.size() << end; + beginInsertRows(QModelIndex(), _data.size(), end); + _data.reserve(end + 1); + for (int i = start; i < _data.size(); i++) { + _data[i] = posts[i - start]; + } + for (int i = _data.size(); i <= end; i++) { + Q_ASSERT(i >= start); + _data.append(posts[i - start]); + } + endInsertRows(); + emit dataChanged(createIndex(start, 0), createIndex(_data.size() - 1, 0)); + } else { + qDebug() << "Just refresh the data"; + for (int i = start; i < end; i++) { + _data[i] = posts[i - start]; + } + emit dataChanged(createIndex(start, 0), createIndex(end, 0)); + } + } +} + +void TopicModel::update() +{ + if (!_board || _topicId < 0) return; + // Start by requesting an update of the first 20 topics + if (!_board->service()->isAccessible()) return; + QDateTime last = lastTopPostUpdate(); + if (!last.isValid() || + last.secsTo(QDateTime::currentDateTime()) > TOPIC_TOP_TLL) { + // Outdated or empty, refresh. + _board->enqueueAction(new FetchPostsAction(_topicId, 0, TOPIC_PAGE_SIZE - 1, _board)); + } else { + qDebug() << "Topics not outdated"; + } +} + +void TopicModel::reload() +{ + Q_ASSERT(_data.empty()); + Q_ASSERT(!_eof); + fetchMore(); +} diff --git a/topicmodel.h b/topicmodel.h new file mode 100644 index 0000000..95a3f78 --- /dev/null +++ b/topicmodel.h @@ -0,0 +1,72 @@ +#ifndef TOPICMODEL_H +#define TOPICMODEL_H + +#include <QtCore/QAbstractListModel> +#include <QtCore/QDateTime> +#include <QtSql/QSqlQuery> + +class Board; + +class TopicModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(QString boardUrl READ boardUrl WRITE setBoardUrl NOTIFY boardUrlChanged) + Q_PROPERTY(int topicId READ topicId WRITE setTopicId NOTIFY topicIdChanged) + +public: + TopicModel(QObject *parent = 0); + + enum DataRoles { + TitleRole = Qt::DisplayRole, + IconRole = Qt::DecorationRole, + ContentRole = Qt::ToolTipRole, + + PostIdRole = Qt::UserRole + }; + + QString boardUrl() const; + void setBoardUrl(const QString& url); + + int topicId() const; + void setTopicId(const int id); + + int rowCount(const QModelIndex &parent = QModelIndex()) const; + QVariant data(const QModelIndex &index, int role) const; + + bool canFetchMore(const QModelIndex &parent = QModelIndex()) const; + void fetchMore(const QModelIndex &parent = QModelIndex()); + +signals: + void boardUrlChanged(); + void topicIdChanged(); + +protected: + struct Post { + int post_id; + QString title; + QString content; + QDateTime time; + QDateTime last_update_time; + }; + +private: + static QDateTime parseDateTime(const QVariant& v); + static QDateTime oldestPostUpdate(const QList<Post>& posts); + QDateTime lastTopPostUpdate(); + QList<Post> loadPosts(int start, int end); + void clearModel(); + +private slots: + void handleTopicPostsChanged(int forumId, int start, int end); + void update(); + void reload(); + +private: + QString _boardUrl; + Board *_board; + int _topicId; + QList<Post> _data; + bool _eof; +}; + +#endif // TOPICMODEL_H diff --git a/xmlrpcinterface.cpp b/xmlrpcinterface.cpp index bf61e12..4d55c8c 100644 --- a/xmlrpcinterface.cpp +++ b/xmlrpcinterface.cpp @@ -17,6 +17,9 @@ XmlRpcPendingCall *XmlRpcInterface::asyncCallWithArgumentList(const QString &met QByteArray data = encodeCall(method, args); request.setHeader(QNetworkRequest::ContentTypeHeader, "text/xml"); request.setRawHeader("User-Agent", "Tapasboard/1.0"); +#if XML_RPC_DEBUG + qDebug() << "Request:" << data; +#endif QNetworkReply *reply = _manager->post(request, data); return new XmlRpcPendingCall(reply, this); } diff --git a/xmlrpcinterface.h b/xmlrpcinterface.h index 1fce4f1..433d194 100644 --- a/xmlrpcinterface.h +++ b/xmlrpcinterface.h @@ -9,6 +9,8 @@ #include "xmlrpcpendingcall.h" +#define XML_RPC_DEBUG 0 + class XmlRpcInterface : public QObject { Q_OBJECT diff --git a/xmlrpcpendingcall.cpp b/xmlrpcpendingcall.cpp index d94b76d..8f318bd 100644 --- a/xmlrpcpendingcall.cpp +++ b/xmlrpcpendingcall.cpp @@ -157,6 +157,9 @@ void XmlRpcPendingCall::handleRequestFinished() QNetworkReply::NetworkError error = _reply->error(); if (error == QNetworkReply::NoError) { QByteArray data = _reply->readAll(); +#if XML_RPC_DEBUG + qDebug() << "Response:" << data; +#endif QXmlStreamReader reader(data); bool parse_ok = false; if (reader.readNextStartElement()) { |