summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNikita Kostovsky <nikita@kostovsky.me>2025-06-22 16:54:02 +0200
committerNikita Kostovsky <nikita@kostovsky.me>2025-06-22 16:54:02 +0200
commitf674e179d602d3ccb9818d28fe06f371059449dc (patch)
tree996fb624986512de91581a18332f004d34220ba2
parse and insert feeds and items
-rw-r--r--.gitignore82
-rw-r--r--CMakeLists.txt28
-rw-r--r--src/atomchannel.cpp197
-rw-r--r--src/atomchannel.h69
-rw-r--r--src/atomchannelimage.cpp69
-rw-r--r--src/atomchannelimage.h32
-rw-r--r--src/atomitem.cpp180
-rw-r--r--src/atomitem.h66
-rw-r--r--src/constants.h23
-rw-r--r--src/iatomobject.h9
-rw-r--r--src/idbobject.h24
-rw-r--r--src/macros.h4
-rw-r--r--src/main.cpp90
-rw-r--r--src/playground.cpp108
-rw-r--r--src/playground.h27
-rw-r--r--src/rsshit.qrc5
-rw-r--r--src/rsshit_db.cpp60
-rw-r--r--src/rsshit_db.h17
-rw-r--r--src/sql/db.sqlitebin0 -> 36864 bytes
19 files changed, 1090 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa3808c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,82 @@
+# This file is used to ignore files which are generated
+# ----------------------------------------------------------------------------
+
+*~
+*.autosave
+*.a
+*.core
+*.moc
+*.o
+*.obj
+*.orig
+*.rej
+*.so
+*.so.*
+*_pch.h.cpp
+*_resource.rc
+*.qm
+.#*
+*.*#
+core
+!core/
+tags
+.DS_Store
+.directory
+*.debug
+Makefile*
+*.prl
+*.app
+moc_*.cpp
+ui_*.h
+qrc_*.cpp
+Thumbs.db
+*.res
+*.rc
+/.qmake.cache
+/.qmake.stash
+
+# qtcreator generated files
+*.pro.user*
+*.qbs.user*
+CMakeLists.txt.user*
+
+# xemacs temporary files
+*.flc
+
+# Vim temporary files
+.*.swp
+
+# Visual Studio generated files
+*.ib_pdb_index
+*.idb
+*.ilk
+*.pdb
+*.sln
+*.suo
+*.vcproj
+*vcproj.*.*.user
+*.ncb
+*.sdf
+*.opensdf
+*.vcxproj
+*vcxproj.*
+
+# MinGW generated files
+*.Debug
+*.Release
+
+# Python byte code
+*.pyc
+
+# Binaries
+# --------
+*.dll
+*.exe
+
+# Directories with generated files
+.moc/
+.obj/
+.pch/
+.rcc/
+.uic/
+/build*/
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..f5ca22e
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,28 @@
+cmake_minimum_required(VERSION 3.16)
+
+project(rsshit LANGUAGES CXX)
+
+set(CMAKE_AUTOUIC ON)
+set(CMAKE_AUTOMOC ON)
+set(CMAKE_AUTORCC ON)
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core Network Sql)
+find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Network Sql)
+
+file(GLOB_RECURSE SOURCES src/*.h src/*.cpp src/*.qrc)
+add_executable(${PROJECT_NAME} ${SOURCES})
+
+target_link_libraries(${PROJECT_NAME}
+ Qt${QT_VERSION_MAJOR}::Core
+ Qt${QT_VERSION_MAJOR}::Network
+ Qt${QT_VERSION_MAJOR}::Sql
+)
+
+include(GNUInstallDirs)
+install(TARGETS ${PROJECT_NAME}
+ LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
+ RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
+)
diff --git a/src/atomchannel.cpp b/src/atomchannel.cpp
new file mode 100644
index 0000000..6982e8b
--- /dev/null
+++ b/src/atomchannel.cpp
@@ -0,0 +1,197 @@
+#include "atomchannel.h"
+
+#include <QSqlError>
+#include <QSqlQuery>
+#include <QXmlStreamReader>
+
+#include "constants.h"
+#include "macros.h"
+#include "rsshit_db.h"
+
+AtomChannel::AtomChannel(QXmlStreamReader *xmlReader)
+{
+ Q_ASSERT(xmlReader != nullptr);
+
+ const QString titleTag{"title"};
+ const QString linkTag{"link"};
+ const QString descriptionTag{"description"};
+ const QString lastBuildDateTag{"lastBuildDate"};
+ const QString languageTag{"language"};
+
+ while (!xmlReader->atEnd() && !xmlReader->hasError()) {
+ const auto itemNext = xmlReader->readNext();
+
+ switch (itemNext) {
+ case QXmlStreamReader::TokenType::StartElement: {
+ const auto name = xmlReader->name();
+ // qDebug() << __func__ << ": StartElement" << name;
+ // qDebug() << __func__ << "namespaceUri:" << xmlReader->namespaceUri();
+ // qDebug() << __func__ << "prefix:" << xmlReader->prefix();
+ // qDebug() << __func__ << "qualifiedName:" << xmlReader->qualifiedName();
+ // const auto elementText = xmlReader->readElementText();
+
+ if (name == titleTag)
+ title = xmlReader->readElementText();
+ else if (name == linkTag)
+ link = xmlReader->readElementText();
+ else if (name == descriptionTag)
+ description = xmlReader->readElementText();
+ else if (name == lastBuildDateTag)
+ lastBuildDate = QDateTime::fromString(xmlReader->readElementText(),
+ Qt::DateFormat::RFC2822Date);
+ else if (name == languageTag)
+ language = QLocale{xmlReader->readElementText()}.language();
+ else if (name == AtomChannelImage::tag) {
+ // qDebug() << "got image tag";
+ image = AtomChannelImage{xmlReader};
+ // qDebug() << image;
+ } else if (name == AtomItem::tag) {
+ items << AtomItem{xmlReader};
+ // qDebug() << items.constLast();
+ // qDebug() << __func__ << "got feed item";
+ } else {
+ // qDebug() << "exit" << __func__;
+ // qDebug() << __func__ << "unknown tag:" << name;
+ continue;
+ }
+
+ break;
+ }
+ case QXmlStreamReader::TokenType::EndElement: {
+ // qDebug() << "EndElement: " << xmlReader->name();
+
+ if (xmlReader->name() == AtomChannel::xmlTag)
+ return;
+ }
+ case QXmlStreamReader::TokenType::Characters: {
+ const auto characters = xmlReader->text().toString().simplified();
+
+ if (characters.isEmpty())
+ break;
+
+ qDebug() << "channel: characters: " << characters;
+ break;
+ }
+ }
+ }
+
+ qDebug() << "exit " << __func__;
+}
+
+int AtomChannel::getDbId()
+{
+ if (dbId != rsshit::db::IdNotFound)
+ return dbId;
+
+ const auto db = rsshit::db::open();
+
+ if (!db)
+ return rsshit::db::IdNotFound;
+
+ QSqlQuery selectQ{"select id from feeds where link=?"};
+ selectQ.addBindValue(link);
+
+ if (!selectQ.exec()) {
+ qWarning() << "cannot exec query" << selectQ.lastQuery() << ":"
+ << selectQ.lastError().text() << ":" << selectQ.executedQuery();
+
+ return rsshit::db::IdNotFound;
+ }
+
+ if (!selectQ.next())
+ return rsshit::db::IdNotFound;
+
+ const auto idVariant = selectQ.value(rsshit::db::idTag);
+
+ if (!idVariant.isValid() || !idVariant.canConvert<int>())
+ return rsshit::db::IdNotFound;
+
+ bool ok{false};
+ const auto result = idVariant.toInt(&ok);
+
+ if (!ok) {
+ qWarning() << "got invalid id from db:" << idVariant;
+
+ return rsshit::db::IdNotFound;
+ }
+
+ return result;
+}
+
+int AtomChannel::createInDb()
+{
+ if (dbId != rsshit::db::IdNotFound)
+ return dbId;
+
+ const auto db = rsshit::db::open();
+
+ if (!db)
+ return rsshit::db::IdNotFound;
+
+ QSqlQuery insertQ{"insert into feeds(link, title, image_url) values(?, ?, ?)"};
+ insertQ.addBindValue(link);
+ insertQ.addBindValue(title);
+ insertQ.addBindValue(image.url);
+
+ if (!insertQ.exec()) {
+ qWarning() << "cannot exec query" << insertQ.lastQuery() << ":"
+ << insertQ.lastError().text() << ":" << insertQ.executedQuery();
+
+ return rsshit::db::IdNotFound;
+ }
+
+ return insertQ.lastInsertId().toInt();
+}
+
+// TODO: can be moved to IDbObject
+int AtomChannel::getOrInsertDbId()
+{
+ const auto id = getDbId();
+
+ if (id != rsshit::db::IdNotFound)
+ return id;
+
+ return createInDb();
+}
+
+QList<int> AtomChannel::syncDbItems()
+{
+ if (dbId == rsshit::db::IdNotFound)
+ dbId = getOrInsertDbId();
+
+ if (dbId == rsshit::db::IdNotFound)
+ return {};
+
+ QList<int> result;
+
+ for (auto &item : items) {
+ auto id = item.getOrInsertDbId(this->dbId);
+
+ if (id != rsshit::db::IdNotFound)
+ result << id;
+ }
+
+ return result;
+}
+
+QDebug operator<<(QDebug debug, const AtomChannel &channel)
+{
+ QDebugStateSaver saver{debug};
+
+ debug.nospace() << typeid(AtomChannel).name() << " {" << Qt::endl;
+
+ PRINT_ATOM_FIELD(channel, title);
+ PRINT_ATOM_FIELD(channel, link);
+ PRINT_ATOM_FIELD(channel, description);
+ PRINT_ATOM_FIELD(channel, lastBuildDate);
+ PRINT_ATOM_FIELD(channel, language);
+ debug << "\timage:\n" << channel.image << Qt::endl;
+ debug << "\titems count:" << channel.items.size() << Qt::endl;
+
+ for (const auto &item : channel.items)
+ debug << item << Qt::endl;
+
+ debug.nospace() << "}";
+
+ return debug;
+}
diff --git a/src/atomchannel.h b/src/atomchannel.h
new file mode 100644
index 0000000..0498a64
--- /dev/null
+++ b/src/atomchannel.h
@@ -0,0 +1,69 @@
+#pragma once
+
+#include <QDateTime>
+#include <QDebug>
+#include <QList>
+#include <QString>
+
+#include "atomchannelimage.h"
+#include "atomitem.h"
+
+class QXmlStreamReader;
+
+class AtomChannel
+{
+public:
+ // TODO: move to interface
+ static inline const QString xmlTag{"channel"};
+
+public:
+ explicit AtomChannel(QXmlStreamReader *xmlReader);
+
+public:
+ /*!
+ * \brief getDbId - check if channel with corresponding `link` exists in db
+ * and return db id if any
+ * \return id on success, 0 otherwise
+ */
+ int getDbId();
+
+ /*!
+ * \brief createInDb - create channel in db
+ * \return new channel id on success, 0 otherwise
+ */
+ int createInDb();
+
+ /*!
+ * \brief getOrInsertDbId - get existing channel id or try to create a new
+ * channel and get its id
+ * \return existing or new channel id on success, 0 otherwise
+ */
+ int getOrInsertDbId();
+
+ /*!
+ * \brief syncDbItems - create items in db if not exist
+ * \return list of new items ids
+ */
+ QList<int> syncDbItems();
+
+public:
+ /*!
+ * \brief dbId - cache db id
+ */
+ int dbId{0};
+
+ QString title;
+ /*!
+ * \brief link - field called `link` in atom xml. Points to feed itself
+ */
+ QUrl link;
+ QString description;
+ QDateTime lastBuildDate;
+ QLocale::Language language;
+ // ...
+ // TODO: shared_ptr
+ AtomChannelImage image;
+ QList<AtomItem> items;
+};
+
+QDebug operator<<(QDebug debug, const AtomChannel &channel);
diff --git a/src/atomchannelimage.cpp b/src/atomchannelimage.cpp
new file mode 100644
index 0000000..4b9210d
--- /dev/null
+++ b/src/atomchannelimage.cpp
@@ -0,0 +1,69 @@
+#include "atomchannelimage.h"
+
+#include <QXmlStreamReader>
+
+#include "macros.h"
+
+AtomChannelImage::AtomChannelImage(QXmlStreamReader *xmlReader)
+{
+ Q_ASSERT(xmlReader != nullptr);
+
+ const QString urlTag{"url"};
+ const QString titleTag{"title"};
+ const QString linkTag{"link"};
+ const QString widthTag{"width"};
+ const QString heightTag{"height"};
+
+ while (!xmlReader->atEnd() && !xmlReader->hasError()) {
+ const auto itemNext = xmlReader->readNext();
+
+ switch (itemNext) {
+ case QXmlStreamReader::TokenType::StartElement: {
+ const auto name = xmlReader->name();
+ // qDebug() << __func__ << ":" << name;
+ const auto elementText = xmlReader->readElementText();
+
+ if (name == urlTag)
+ url = elementText;
+ else if (name == titleTag)
+ title = elementText;
+ else if (name == linkTag)
+ link = elementText;
+ else if (name == widthTag)
+ width = elementText.toInt();
+ else if (name == heightTag)
+ height = elementText.toInt();
+
+ break;
+ }
+ case QXmlStreamReader::TokenType::EndElement:
+ // qDebug() << "EndElement: " << xmlReader->name();
+ return;
+ case QXmlStreamReader::TokenType::Characters:
+ const auto characters = xmlReader->text().toString().simplified();
+
+ if (characters.isEmpty())
+ break;
+
+ qDebug() << "image: characters: " << characters;
+ break;
+ }
+ }
+}
+
+QDebug operator<<(QDebug debug, const AtomChannelImage &image)
+{
+ QDebugStateSaver saver{debug};
+
+ debug.nospace() << typeid(AtomChannelImage).name() << " {" << Qt::endl;
+
+ PRINT_ATOM_FIELD(image, url);
+ PRINT_ATOM_FIELD(image, title);
+ PRINT_ATOM_FIELD(image, link);
+ PRINT_ATOM_FIELD(image, width);
+ PRINT_ATOM_FIELD(image, height);
+
+ debug.nospace() << "}";
+
+ return debug;
+}
diff --git a/src/atomchannelimage.h b/src/atomchannelimage.h
new file mode 100644
index 0000000..8c43034
--- /dev/null
+++ b/src/atomchannelimage.h
@@ -0,0 +1,32 @@
+#pragma once
+
+#include <QDebug>
+#include <QString>
+#include <QUrl>
+
+class QXmlStreamReader;
+
+struct AtomChannelImage
+{
+ // TODO: move to interface
+ static inline const QString tag{"image"};
+
+ // TODO: remove, use shared_ptr in AtomChannel
+ AtomChannelImage() = default;
+ explicit AtomChannelImage(QXmlStreamReader *xmlReader);
+
+ /*!
+ * \brief url - url of image file
+ */
+ QUrl url;
+ QString title;
+
+ /*!
+ * \brief link - link to website
+ */
+ QUrl link;
+ size_t width{0};
+ size_t height{0};
+};
+
+QDebug operator<<(QDebug debug, const AtomChannelImage &image);
diff --git a/src/atomitem.cpp b/src/atomitem.cpp
new file mode 100644
index 0000000..5f099f6
--- /dev/null
+++ b/src/atomitem.cpp
@@ -0,0 +1,180 @@
+#include "atomitem.h"
+
+#include <QSqlError>
+#include <QSqlQuery>
+#include <QXmlStreamReader>
+
+#include "atomchannel.h"
+#include "constants.h"
+#include "macros.h"
+#include "rsshit_db.h"
+
+AtomItem::AtomItem(QXmlStreamReader *xmlReader)
+{
+ Q_ASSERT(xmlReader != nullptr);
+
+ const QString titleTag{"title"};
+ const QString linkTag{"link"};
+ const QString categoryTag{"category"};
+ const QString guidTag{"guid"};
+ const QString pubDateTag{"pubDate"};
+ const QString descriptionTag{"description"};
+ const QString encodedTag{"encoded"};
+
+ while (!xmlReader->atEnd() && !xmlReader->hasError()) {
+ const auto itemNext = xmlReader->readNext();
+
+ switch (itemNext) {
+ case QXmlStreamReader::TokenType::StartElement: {
+ const auto name = xmlReader->name();
+ const auto elementText = xmlReader->readElementText();
+
+ if (name == titleTag)
+ title = elementText;
+ else if (name == linkTag)
+ link = elementText;
+ else if (name == categoryTag)
+ categories.append(elementText);
+ else if (name == guidTag)
+ guid = AtomGuid{elementText};
+ else if (name == pubDateTag)
+ pubDate = QDateTime::fromString(elementText, Qt::DateFormat::RFC2822Date);
+ else if (name == descriptionTag)
+ description = elementText;
+ else if (name == encodedTag)
+ encoded = elementText;
+
+ break;
+ }
+ case QXmlStreamReader::TokenType::EndElement:
+ // qDebug() << "EndElement: " << xmlReader->name();
+ return;
+ case QXmlStreamReader::TokenType::Characters:
+ const auto characters = xmlReader->text().toString().simplified();
+
+ if (characters.isEmpty())
+ break;
+
+ qDebug() << "item: characters: " << characters;
+ break;
+ }
+ }
+}
+
+int AtomItem::getDbId()
+{
+ if (dbId != rsshit::db::IdNotFound)
+ return dbId;
+
+ const auto db = rsshit::db::open();
+
+ if (!db)
+ return rsshit::db::IdNotFound;
+
+ QSqlQuery selectQ{"select id from items where link=?"};
+ selectQ.addBindValue(link);
+
+ if (!selectQ.exec()) {
+ qWarning() << "cannot exec query" << selectQ.lastQuery() << ":"
+ << selectQ.lastError().text() << ":" << selectQ.executedQuery();
+
+ return rsshit::db::IdNotFound;
+ }
+
+ if (!selectQ.next())
+ return rsshit::db::IdNotFound;
+
+ const auto idVariant = selectQ.value(rsshit::db::idTag);
+
+ if (!idVariant.isValid() || !idVariant.canConvert<int>())
+ return rsshit::db::IdNotFound;
+
+ bool ok{false};
+ const auto result = idVariant.toInt(&ok);
+
+ if (!ok) {
+ qWarning() << "got invalid id from db:" << idVariant;
+
+ return rsshit::db::IdNotFound;
+ }
+
+ return result;
+}
+
+int AtomItem::createInDb(const int feedId)
+{
+ if (dbId != rsshit::db::IdNotFound)
+ return dbId;
+
+ const auto db = rsshit::db::open();
+
+ if (!db)
+ return rsshit::db::IdNotFound;
+
+ QSqlQuery insertQ{
+ "insert into items(feed_fk, pub_datetime_unix, title, link, author, description)"
+ "values(?, ?, ?, ?, ?, ?)"};
+
+ insertQ.addBindValue(feedId);
+ insertQ.addBindValue(pubDate.toSecsSinceEpoch());
+ insertQ.addBindValue(title);
+ insertQ.addBindValue(link);
+ insertQ.addBindValue(author);
+ insertQ.addBindValue(description);
+
+ if (!insertQ.exec()) {
+ qWarning() << "cannot exec query" << insertQ.lastQuery() << ":"
+ << insertQ.lastError().text() << ":" << insertQ.executedQuery();
+
+ return rsshit::db::IdNotFound;
+ }
+
+ return insertQ.lastInsertId().toInt();
+}
+
+int AtomItem::getOrInsertDbId(const int feedId)
+{
+ const auto id = getDbId();
+
+ if (id != rsshit::db::IdNotFound)
+ return id;
+
+ return createInDb(feedId);
+}
+
+QDebug operator<<(QDebug debug, const AtomItem &item)
+{
+ QDebugStateSaver saver{debug};
+
+ debug.nospace() << typeid(AtomItem).name() << " {" << Qt::endl;
+
+ PRINT_ATOM_FIELD(item, title);
+ PRINT_ATOM_FIELD(item, link);
+ PRINT_ATOM_FIELD(item, categories);
+ PRINT_ATOM_FIELD(item, guid);
+ PRINT_ATOM_FIELD(item, pubDate);
+ // PRINT_ATOM_ITEM_FIELD(description);
+ // PRINT_ATOM_ITEM_FIELD(encoded);
+
+ auto halfSize = item.description.size() / 2;
+ constexpr decltype(halfSize) maxLeft{70};
+ constexpr decltype(halfSize) maxRight{20};
+
+ auto left = std::min(maxLeft, halfSize);
+ auto right = std::min(maxRight, halfSize);
+
+ debug.nospace().noquote() << "\tdescription: \"" << item.description.left(left) << "..."
+ << item.description.right(right) << '"' << Qt::endl;
+
+ halfSize = item.encoded.size() / 2;
+
+ left = std::min(maxLeft, halfSize);
+ right = std::min(maxRight, halfSize);
+
+ debug.nospace().noquote() << "\tencoded: \"" << item.encoded.left(left) << "..."
+ << item.encoded.right(right) << '"' << Qt::endl;
+
+ debug.nospace() << "}";
+
+ return debug;
+}
diff --git a/src/atomitem.h b/src/atomitem.h
new file mode 100644
index 0000000..39917b5
--- /dev/null
+++ b/src/atomitem.h
@@ -0,0 +1,66 @@
+#pragma once
+
+#include <QDateTime>
+#include <QDebug>
+#include <QString>
+#include <QUrl>
+
+class QXmlStreamReader;
+
+class AtomChannel;
+
+struct AtomGuid : public QString
+{
+ using QString::QString;
+
+ // NOTE: unused
+ bool isPermalink{false};
+};
+
+class AtomItem
+{
+public:
+ // TODO: move to interface
+ static inline const QString tag{"item"};
+
+public:
+ explicit AtomItem(QXmlStreamReader *xmlReader);
+
+public:
+ /*!
+ * \brief getDbId - check if item with corresponding `link` exists in db
+ * and return db id if any
+ * \return id on success, 0 otherwise
+ */
+ int getDbId();
+
+ /*!
+ * \brief createInDb - create item in db
+ * \return new item id on success, 0 otherwise
+ */
+ int createInDb(const int feedId);
+
+ /*!
+ * \brief getOrInsertDbId - get existing item id or try to create a new
+ * item and get its id
+ * \return existing or new item id on success, 0 otherwise
+ */
+ int getOrInsertDbId(const int feedId);
+
+public:
+ /*!
+ * \brief dbId - cache db id
+ */
+ int dbId{0};
+
+ QString title;
+ QUrl link;
+ QString author;
+ QStringList categories;
+ AtomGuid guid;
+ QDateTime pubDate;
+ QString description;
+ QString encoded;
+};
+
+QDebug operator<<(QDebug debug, const AtomItem &item);
diff --git a/src/constants.h b/src/constants.h
new file mode 100644
index 0000000..c27b777
--- /dev/null
+++ b/src/constants.h
@@ -0,0 +1,23 @@
+#pragma once
+
+namespace rsshit {
+namespace db {
+// TODO: read from config
+const QString dbSqliteResourceFilename{":/sql/db.sqlite"};
+const QString driver{"QSQLITE"};
+
+const QString feedsTableName{"feeds"};
+
+const QString idTag{"id"};
+const QString linkTag{"link"};
+
+constexpr int IdNotFound{0};
+
+namespace feeds_field_names {
+const QString link{"link"};
+const QString title{"title"};
+const QString image_url{"image_url"};
+} // namespace feeds_field_names
+
+} // namespace db
+} // namespace rsshit
diff --git a/src/iatomobject.h b/src/iatomobject.h
new file mode 100644
index 0000000..f6e28b1
--- /dev/null
+++ b/src/iatomobject.h
@@ -0,0 +1,9 @@
+#pragma once
+
+// #include <QString>
+
+// class IAtomObject
+// {
+// public:
+// virtual QString tag() const = 0;
+// };
diff --git a/src/idbobject.h b/src/idbobject.h
new file mode 100644
index 0000000..8b04551
--- /dev/null
+++ b/src/idbobject.h
@@ -0,0 +1,24 @@
+#pragma once
+
+class IDbObject
+{
+public:
+ /*!
+ * \brief getDbId - check if object exists in db and return its id if any
+ * \return id on success, 0 otherwise
+ */
+ virtual int getDbId() = 0;
+
+ /*!
+ * \brief createInDb - create object in db
+ * \return new object id on success, 0 otherwise
+ */
+ virtual int createInDb() = 0;
+
+ /*!
+ * \brief getOrInsertDbId - get existing object id or try to create a new
+ * object and get its id
+ * \return existing or new object id on success, 0 otherwise
+ */
+ virtual int getOrInsertDbId() = 0;
+};
diff --git a/src/macros.h b/src/macros.h
new file mode 100644
index 0000000..2459230
--- /dev/null
+++ b/src/macros.h
@@ -0,0 +1,4 @@
+#pragma once
+
+#define PRINT_ATOM_FIELD(object, field) \
+ debug.nospace() << "\t" << #field << ": " << object.field << Qt::endl
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..f9fab22
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,90 @@
+#include <QCoreApplication>
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QStandardPaths>
+#include <QTimer>
+
+#include "constants.h"
+#include "playground.h"
+#include "rsshit_db.h"
+
+int main(int argc, char *argv[])
+{
+ QCoreApplication a(argc, argv);
+
+ // Set up code that uses the Qt event loop here.
+ // Call a.quit() or a.exit() to quit the application.
+ // A not very useful example would be including
+ // #include <QTimer>
+ // near the top of the file and calling
+ // QTimer::singleShot(5000, &a, &QCoreApplication::quit);
+ // which quits the application after 5 seconds.
+
+ // If you do not need a running Qt event loop, remove the call
+ // to a.exec() or use the Non-Qt Plain C++ Application template.
+
+ QFile dbSqliteResource{rsshit::db::dbSqliteResourceFilename};
+ const auto dataLocation = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
+ qDebug() << "data location:" << dataLocation;
+
+ if (!QDir{}.mkpath(dataLocation)) {
+ qWarning() << "cannot create " << dataLocation;
+ return EXIT_FAILURE;
+ }
+
+ const QString newDbFilepath{dataLocation + '/' + QFileInfo{dbSqliteResource}.fileName()};
+
+ // #define RECREATE_DB_FILE
+
+#ifdef RECREATE_DB_FILE
+ if (!QFile::remove(newDbFilepath)) {
+ qWarning() << "cannot remove" << newDbFilepath;
+ }
+
+ if (!dbSqliteResource.copy(newDbFilepath)) {
+ qWarning() << "cannot copy db file (" << dbSqliteResource.fileName()
+ << ") from resources to data dir (" << newDbFilepath
+ << "):" << dbSqliteResource.errorString();
+ return EXIT_FAILURE;
+ }
+#endif
+
+ QFile newDbFile{newDbFilepath};
+
+ if (!newDbFile.setPermissions(QFileDevice::Permission::ReadUser
+ | QFileDevice::Permission::WriteUser)) {
+ qWarning() << "cannot set file permissions:" << newDbFile.fileName() << Qt::endl
+ << newDbFile.errorString();
+
+ return EXIT_FAILURE;
+ }
+
+ Playground playground;
+
+ QTimer::singleShot(0, [&]() {
+ // const QString url{"https://jenngott.com/feed/"};
+ // const QUrl url{"https://liliputing.com/feed"};
+ // playground.fetchUrl(url);
+ // return;
+ // QFile f{"/tmp/rss/feed"};
+ QFile f{"/home/nikita/tmp/rss/feed"};
+ // QFile f{"/tmp/rss/feed.1"};
+ // QFile f{"/tmp/rss/rss"};
+
+ if (!f.open(QFile::ReadOnly)) {
+ qWarning() << "cannot open file" << f.fileName() << ":" << f.errorString();
+ return;
+ }
+
+ const auto channel = playground.parseFeed(&f);
+
+ if (!applyToDb(channel)) {
+ qWarning() << "cannot apply channel to db";
+
+ return;
+ }
+ });
+
+ return a.exec();
+}
diff --git a/src/playground.cpp b/src/playground.cpp
new file mode 100644
index 0000000..476d716
--- /dev/null
+++ b/src/playground.cpp
@@ -0,0 +1,108 @@
+#include "playground.h"
+
+#include <QDebug>
+#include <QLocale>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QTimer>
+#include <QXmlStreamReader>
+
+#include "atomchannel.h"
+#include "macros.h"
+
+Playground::Playground()
+ : m_networkManager{new QNetworkAccessManager}
+{
+ connect(m_networkManager, &QNetworkAccessManager::finished, this, &Playground::onReplyFinished);
+}
+
+void Playground::fetchUrl(const QUrl &url)
+{
+ m_networkManager->get(QNetworkRequest{url});
+}
+
+void Playground::onReplyFinished(QNetworkReply *reply)
+{
+ // TODO: check for nullptr
+
+ reply->deleteLater();
+
+ qDebug() << "reply size:" << reply->size();
+
+ parseFeed(reply);
+}
+
+std::shared_ptr<AtomChannel> Playground::parseFeed(QIODevice *ioDevice)
+{
+ // TODO: check for nullptr
+ // TODO: try `QNetworkReply::readyRead` instead of `QNetworkAccessManager::finished`
+ std::shared_ptr<AtomChannel> channel{};
+ QXmlStreamReader xmlReader{ioDevice};
+
+ while (!xmlReader.atEnd() && !xmlReader.hasError()) {
+ const auto next = xmlReader.readNext();
+
+ // qDebug() << "\tnext:" << next;
+
+ switch (next) {
+ case QXmlStreamReader::TokenType::NoToken:
+ qDebug() << "NoToken";
+ break;
+ case QXmlStreamReader::TokenType::Invalid:
+ qDebug() << "Invalid";
+ break;
+ case QXmlStreamReader::TokenType::StartDocument:
+ // qDebug() << "StartDocument";
+ break;
+ case QXmlStreamReader::TokenType::EndDocument:
+ // qDebug() << "EndDocument";
+ break;
+ case QXmlStreamReader::TokenType::StartElement: {
+ const auto startElementName = xmlReader.name();
+ // qDebug() << "StartElement: " << startElementName;
+
+ if (startElementName == AtomChannel::xmlTag) {
+ channel = std::make_shared<AtomChannel>(&xmlReader);
+ // qDebug() << *channel;
+ } /* else if (startElementName == itemTag) {
+ const AtomItem item{&xmlReader};
+ qDebug() << item;
+ }*/
+
+ break;
+ }
+ case QXmlStreamReader::TokenType::EndElement:
+ // qDebug() << "EndElement: " << xmlReader.name();
+ break;
+ case QXmlStreamReader::TokenType::Characters: {
+ const auto characters = xmlReader.text().toString().simplified();
+
+ if (characters.isEmpty())
+ break;
+
+ qDebug() << "Characters";
+ break;
+ }
+ case QXmlStreamReader::TokenType::Comment:
+ qDebug() << "Comment";
+ break;
+ case QXmlStreamReader::TokenType::DTD:
+ qDebug() << "DTD";
+ break;
+ case QXmlStreamReader::TokenType::EntityReference:
+ qDebug() << "EntityReference";
+ break;
+ case QXmlStreamReader::TokenType::ProcessingInstruction:
+ qDebug() << "ProcessingInstruction";
+ break;
+ }
+
+ if (xmlReader.hasError()) {
+ qWarning() << "xml parsing error:" << xmlReader.errorString();
+
+ return {};
+ }
+ }
+
+ return channel;
+}
diff --git a/src/playground.h b/src/playground.h
new file mode 100644
index 0000000..784b06b
--- /dev/null
+++ b/src/playground.h
@@ -0,0 +1,27 @@
+#pragma once
+
+#include <QDateTime>
+#include <QObject>
+#include <QSize>
+#include <QUrl>
+
+class QNetworkAccessManager;
+class QNetworkReply;
+
+class AtomChannel;
+
+class Playground : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit Playground();
+
+public slots:
+ void fetchUrl(const QUrl &url);
+ void onReplyFinished(QNetworkReply *reply);
+ std::shared_ptr<AtomChannel> parseFeed(QIODevice *ioDevice);
+
+private:
+ QNetworkAccessManager *m_networkManager{nullptr};
+};
diff --git a/src/rsshit.qrc b/src/rsshit.qrc
new file mode 100644
index 0000000..ccc5399
--- /dev/null
+++ b/src/rsshit.qrc
@@ -0,0 +1,5 @@
+<RCC>
+ <qresource prefix="/">
+ <file>sql/db.sqlite</file>
+ </qresource>
+</RCC>
diff --git a/src/rsshit_db.cpp b/src/rsshit_db.cpp
new file mode 100644
index 0000000..d5d2a2a
--- /dev/null
+++ b/src/rsshit_db.cpp
@@ -0,0 +1,60 @@
+#include "rsshit_db.h"
+
+#include <QDebug>
+#include <QFileInfo>
+#include <QSqlDatabase>
+#include <QSqlError>
+#include <QSqlQuery>
+#include <QSqlRecord>
+#include <QSqlResult>
+#include <QSqlTableModel>
+#include <QStandardPaths>
+
+#include "atomchannel.h"
+#include "constants.h"
+#include "playground.h"
+
+namespace {
+namespace feeds_field_names {
+const QString link{"link"};
+const QString title{"title"};
+const QString image_url{"image_url"};
+} // namespace feeds_field_names
+} // namespace
+
+bool applyToDb(const std::shared_ptr<AtomChannel> channel)
+{
+ if (channel->getOrInsertDbId() == rsshit::db::IdNotFound)
+ return false;
+
+ if (channel->items.isEmpty())
+ return true;
+
+ channel->syncDbItems();
+
+ return true;
+}
+
+std::optional<QSqlDatabase> rsshit::db::open()
+{
+ auto db = QSqlDatabase::database();
+
+ if (db.isValid() && db.isOpen())
+ return db;
+
+ db = QSqlDatabase::addDatabase(rsshit::db::driver);
+ const auto dataLocation = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
+ const QString dbFilepath{dataLocation + '/'
+ + QFileInfo{rsshit::db::dbSqliteResourceFilename}.fileName()};
+
+ db.setDatabaseName(dbFilepath);
+ qDebug() << "open" << dbFilepath;
+
+ if (!db.open()) {
+ qWarning() << "cannot open db:" << db.lastError().text();
+
+ return {};
+ }
+
+ return db;
+}
diff --git a/src/rsshit_db.h b/src/rsshit_db.h
new file mode 100644
index 0000000..f4dbeee
--- /dev/null
+++ b/src/rsshit_db.h
@@ -0,0 +1,17 @@
+#pragma once
+
+#include <memory>
+#include <optional>
+
+#include <QSqlDatabase>
+#include <QString>
+
+struct AtomChannel;
+
+namespace rsshit {
+namespace db {
+std::optional<QSqlDatabase> open();
+} // namespace db
+} // namespace rsshit
+
+bool applyToDb(const std::shared_ptr<AtomChannel> channel);
diff --git a/src/sql/db.sqlite b/src/sql/db.sqlite
new file mode 100644
index 0000000..c982736
--- /dev/null
+++ b/src/sql/db.sqlite
Binary files differ