Commit d0ab0cc0 authored by Christophe Henry's avatar Christophe Henry Committed by Christophe Henry

Solves #10: fetch subscription images

parent 353f37d1
......@@ -6,6 +6,8 @@ apply plugin: "kotlin-kapt"
apply plugin: "androidx.navigation.safeargs.kotlin"
android {
def schema_location = "$projectDir/src/main/java/fr/chenry/android/freshrss/store/database/migrations".toString()
compileSdkVersion 28
defaultConfig {
applicationId "fr.chenry.android.freshrss"
......@@ -16,23 +18,29 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/fr/chenry/android/freshrss/store/database/migrations".toString()]
arguments = ["room.schemaLocation": schema_location]
}
}
}
sourceSets {
androidTest.assets.srcDirs += files(schema_location)
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
dataBinding {
enabled = true
}
configurations.all {
resolutionStrategy.force 'com.google.code.findbugs:jsr305:1.3.9'
exclude group: 'com.google.guava', module: 'listenablefuture'
resolutionStrategy.force "com.google.code.findbugs:jsr305:1.3.9"
exclude group: "com.google.guava", module: "listenablefuture"
}
applicationVariants.all { variant ->
......@@ -51,6 +59,7 @@ android {
dependencies {
def lifecycle_version = "2.0.0"
def room_version = "2.1.0-alpha04"
def roomigrant_version = "0.1.1"
def fuel_version = "2.0.1"
def jackson_version = "2.9.6"
def espresso_version = "3.1.1"
......@@ -59,8 +68,8 @@ dependencies {
def android_support_version = "28.0.0"
def android_navigation = "1.0.0-rc02"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.legacy:legacy-support-v4:1.0.0"
implementation fileTree(include: ["*.jar"], dir: "libs")
// Kotlin stuff
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
......@@ -75,18 +84,18 @@ dependencies {
implementation "com.android.support:support-core-ui:$android_support_version"
// AndroidX layout
implementation 'androidx.appcompat:appcompat:1.1.0-alpha02'
implementation 'androidx.core:core-ktx:1.1.0-alpha03'
implementation 'com.google.android.material:material:1.0.0-beta01'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation "androidx.appcompat:appcompat:1.1.0-alpha02"
implementation "androidx.core:core-ktx:1.1.0-alpha03"
implementation "com.google.android.material:material:1.0.0-beta01"
implementation "androidx.constraintlayout:constraintlayout:1.1.3"
implementation "androidx.recyclerview:recyclerview:1.0.0"
// ViewModel and LiveData
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycle_version"
annotationProcessor "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
testImplementation "androidx.arch.core:core-testing:$lifecycle_version"
androidTestImplementation "androidx.arch.core:core-testing:$lifecycle_version"
// Room
kapt "androidx.room:room-compiler:$room_version"
......@@ -94,7 +103,10 @@ dependencies {
implementation "androidx.room:room-rxjava2:$room_version"
implementation "androidx.room:room-guava:$room_version"
implementation "androidx.room:room-coroutines:$room_version"
testImplementation "androidx.room:room-testing:$room_version"
androidTestImplementation "androidx.room:room-testing:$room_version"
// roomigrant
implementation "com.github.MatrixDev.Roomigrant:RoomigrantLib:$roomigrant_version"
kapt "com.github.MatrixDev.Roomigrant:RoomigrantCompiler:$roomigrant_version"
// HTTP and promises
implementation "com.github.kittinunf.fuel:fuel:$fuel_version"
......@@ -110,11 +122,12 @@ dependencies {
implementation "android.arch.navigation:navigation-ui-ktx:$android_navigation"
// Utils
implementation 'org.apache.commons:commons-text:1.4'
implementation 'joda-time:joda-time:2.10.1'
implementation "org.apache.commons:commons-text:1.4"
implementation "joda-time:joda-time:2.10.1"
implementation "com.squareup.picasso:picasso:2.71828"
// Tests
testImplementation 'junit:junit:4.12'
testImplementation "junit:junit:4.12"
androidTestImplementation "androidx.test:runner:$test_runnner_version"
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
......
INSERT INTO accounts VALUES (1, "User/e3b57290-6bf9-4fab-a2d7-11a35f2301e2", "User/e3b57290-6bf9-4fab-a2d7-11a35f2301e2", "User", "rss.user.tld");
INSERT INTO articles VALUES ("4abf034f-b9df-47a3-8f8b-0094c2b2d61c", "Article 1", "http://article-1.example.com", "user/-/state/com.google/reading-list user/-/label/Vids user/-/state/com.google/read", "The RSS author", "<div><p>This is an article</p></div>", "feed/100", "READ");
INSERT INTO articles VALUES ("26f72931-ad58-44d8-ab0b-a1f82c1b7e20", "Article 2", "http://article-2.example.com", "user/-/state/com.google/reading-list user/-/label/Vids user/-/state/com.google/read", "The RSS author", "<div><p>This is an article</p></div>", "feed/100", "READ");
INSERT INTO articles VALUES ("5d2ee30a-70c4-4ffc-a3b8-a40c0167a094", "Article 3", "http://article-3.example.com", "user/-/state/com.google/reading-list user/-/label/Vids user/-/state/com.google/read", "The RSS author", "<div><p>This is an article</p></div>", "feed/100", "READ");
INSERT INTO articles VALUES ("234c50fd-6901-4cc0-8c66-0aff2538493c", "Article 4", "http://article-4.example.com", "user/-/state/com.google/reading-list user/-/label/Vids user/-/state/com.google/read", "The RSS author", "<div><p>This is an article</p></div>", "feed/100", "READ");
INSERT INTO articles VALUES ("8a41b6cd-96f4-44da-a752-e819ead7cada", "Article 5", "http://article-5.example.com", "user/-/state/com.google/reading-list user/-/label/Vids", "The RSS author", "<div><p>This is an article</p></div>", "feed/100", "UNREAD");
INSERT INTO subscriptions VALUES ("2c21b3b1-8297-445f-a522-2fe8d3ff5083", "Subscription 1", 2);
INSERT INTO subscriptions VALUES ("322faa78-0de5-4ec2-aac0-4b19a2dd10a3", "Subscription 2", 20);
INSERT INTO subscriptions VALUES ("7c0575dd-6e94-440e-85d3-dbe60d5bdbca", "Subscription 3", 0);
INSERT INTO subscriptions VALUES ("ddf69b0d-58ae-40cd-bda0-7311ab339952", "Subscription 4", 6);
INSERT INTO subscriptions VALUES ("54f869cf-a764-4cb5-a77e-c46d8ca798e4", "Subscription 5", 0);
package fr.chenry.android.freshrss.store.database
import android.database.sqlite.SQLiteDatabase
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4
import org.junit.Rule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
abstract class FreshRSSDabatabaseBaseTest {
open val testDB get() = "migration-test"
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
FreshRSSDabatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
val instrumentation get() = InstrumentationRegistry.getInstrumentation()
val assets get() = instrumentation.context.assets
val context get() = instrumentation.context
val targetContext get() = instrumentation.targetContext
private lateinit var _database: SQLiteDatabase
val database: SQLiteDatabase
get() {
if(!::_database.isInitialized || !_database.isOpen) {
_database = SQLiteDatabase.openDatabase(
targetContext.getDatabasePath(testDB).absolutePath,
null, SQLiteDatabase.OPEN_READWRITE
)
}
return _database
}
fun createDatabaseFromScratch(targetVersion: Int) {
helper.createDatabase(testDB, 1).apply {
execSQLWithImports(this, "migration-test-1-before")
migrateDatabase(this, 1, targetVersion)
close()
}
}
fun migrateDatabase(database: SupportSQLiteDatabase, from: Int, to: Int) {
val migrations = FreshRSSDabatabase_Migrations.build()
(from..(to - 1)).forEach {version ->
val migration = migrations[version - 1]
val nextPreMigrationScript = "migration-test-${migration.endVersion}-before.sql"
val nextPostMigrationScript = "migration-test-${migration.endVersion}-after.sql"
assets.list("database")?.find {it == nextPreMigrationScript}?.let {execSQLWithImports(database, it)}
helper.runMigrationsAndValidate(testDB, migration.endVersion, true, migration)
assets.list("database")?.find {it == nextPostMigrationScript}?.let {execSQLWithImports(database, it)}
}
}
fun execSQLWithImports(sqlMgr: SupportSQLiteDatabase, name: String) {
val importRegex = "^--\\s*import\\s*:\\s*(\\S+).*$"
assets.open("database/${name.replace(".sql", "")}.sql").bufferedReader().lines().forEachOrdered {sql ->
if(sql.matches(importRegex.toRegex())) {
val matcher = importRegex.toPattern().matcher(sql)
while(matcher.find()) execSQLWithImports(sqlMgr, matcher.group(1))
} else if(!sql.startsWith("--", true)) {
sqlMgr.execSQL(sql)
}
}
}
}
package fr.chenry.android.freshrss.store.database
import org.junit.Assert.assertEquals
import org.junit.Test
class FreshRSSDabatabaseMigrationsTest: FreshRSSDabatabaseBaseTest() {
@Test
fun allMigrations() {
createDatabaseFromScratch(FreshRSSDabatabase_Migrations.build().size)
}
@Test
fun testMigrate0to2() {
createDatabaseFromScratch(2)
val query = database.rawQuery("SELECT * FROM subscriptions", arrayOf())
assertEquals(5, query.count)
assertEquals(3, query.columnCount)
assertEquals(true, query.moveToFirst())
assertEquals("2c21b3b1-8297-445f-a522-2fe8d3ff5083", query.getString(0))
assertEquals("Subscription 1", query.getString(1))
assertEquals(2, query.getInt(2))
assertEquals(true, query.moveToLast())
assertEquals("54f869cf-a764-4cb5-a77e-c46d8ca798e4", query.getString(0))
assertEquals("Subscription 5", query.getString(1))
assertEquals(0, query.getInt(2))
database.close()
}
@Test
fun testMigrate2to3() {
createDatabaseFromScratch(3)
val query = database.rawQuery("SELECT * FROM subscriptions", arrayOf())
assertEquals(5, query.count)
assertEquals(arrayOf("id", "title", "unreadCount", "imageBitmap", "iconUrl"), query.columnNames)
assertEquals(true, query.moveToFirst())
assertEquals(null, query.getBlob(3))
assertEquals("", query.getString(4))
val bindArgs = arrayOf("54f869cf-a764-4cb5-a77e-c46d8ca798e4")
database.execSQL("UPDATE subscriptions SET iconUrl = 'https://example.com/icon' WHERE id = ?", bindArgs)
database.close()
database.rawQuery("SELECT * FROM subscriptions WHERE id = ?", bindArgs).let {
it.moveToFirst()
assertEquals("https://example.com/icon", it.getString(4))
}
database.close()
}
}
......@@ -8,6 +8,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.os.postDelayed
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.facebook.stetho.Stetho
import fr.chenry.android.freshrss.RefresherService.RefresherBinder
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase
......@@ -40,6 +41,7 @@ class FreshRSSApplication: Application() {
properties.getProperty("debug", "false")!!.toBoolean()
}.getOrDefault(false)
// Debug
//Stetho.initializeWithDefaults(this)
}
......
......@@ -38,9 +38,17 @@ object Store {
.toSuccessVoid()
}
fun getSubscriptions(): Promise<Unit, Exception> = api.getSubscriptions() then {
fun getSubscriptions(): Promise<Unit, Exception> = api.getSubscriptions() bind {
val subscriptions = it.map {self -> Subscription.fromSubscriptionApiItem(self)}
FreshRSSApplication.database.syncSubscriptions(subscriptions)
FreshRSSApplication.database.syncSubscriptions(subscriptions).always {
task {
FreshRSSApplication.database.let {db ->
db.getAllSubcriptionsWithImageToUpdate()
.blockingFirst()
.forEach {sub -> db.insertSubscriptionImage(sub.id, sub.fetchImage())}
}
}
}.toSuccessVoid()
}
fun getUnreadCount(): Promise<Unit, Exception> =
......
......@@ -16,7 +16,8 @@ data class SubscriptionApiItem(
val title: String,
val categories: List<SubscriptionCategory>,
val url: String,
val htmlUrl: String
val htmlUrl: String,
val iconUrl: String
)
data class SubscriptionCategory(
......
package fr.chenry.android.freshrss.store.database
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.room.TypeConverter
import fr.chenry.android.freshrss.store.database.models.ReadStatus
import fr.chenry.android.freshrss.store.database.models.ReadStatus.READ
import fr.chenry.android.freshrss.utils.escapeHtml4
import fr.chenry.android.freshrss.utils.unescapeHtml4
import java.lang.Exception
import java.io.ByteArrayOutputStream
class Converters {
@TypeConverter
......@@ -19,8 +21,19 @@ class Converters {
fun readStatusToString(liveData: ReadStatus) = liveData.name
@TypeConverter
fun listOfStringToString(list: List<String>) = list.map{it.escapeHtml4()}.joinToString(" ")
fun listOfStringToString(list: List<String>) = list.map {it.escapeHtml4()}.joinToString(" ")
@TypeConverter
fun stringToListOfString(string: String) = string.split("\\s+".toRegex()).map {it.unescapeHtml4()}
}
\ No newline at end of file
@TypeConverter
fun bitmapToBlob(bitmap: Bitmap?) =
(if(bitmap == null) ByteArray(0) else
ByteArrayOutputStream().apply {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, this)
}.toByteArray())!!
@TypeConverter
fun blobToBitmap(byteArray: ByteArray?) =
if(byteArray?.isEmpty() != false) null else BitmapFactory.decodeStream(byteArray.inputStream())
}
package fr.chenry.android.freshrss.store.database
import android.graphics.Bitmap
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import dev.matrix.roomigrant.GenerateRoomMigrations
import fr.chenry.android.freshrss.FreshRSSApplication
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.store.api.models.StreamId
import fr.chenry.android.freshrss.store.database.models.*
import fr.chenry.android.freshrss.utils.Try
import fr.chenry.android.freshrss.utils.getOrDefault
import nl.komponents.kovenant.task
import nl.komponents.kovenant.*
import nl.komponents.kovenant.functional.bind
import kotlin.reflect.KProperty
@Database(version = 2, entities = [Account::class, Article::class, Subscription::class])
@Database(version = 3, entities = [Account::class, Article::class, Subscription::class])
@TypeConverters(Converters::class)
@GenerateRoomMigrations
abstract class FreshRSSDabatabase: RoomDatabase() {
private val authTokensDelegate = AuthTokensDelegate()
var account: Account by authTokensDelegate
......@@ -29,26 +31,42 @@ abstract class FreshRSSDabatabase: RoomDatabase() {
getArticlesDAO().getByStreamIdAndUnread(streamId, ReadStatus.UNREAD.name)
fun upsertArticle(article: Article) =
Try{getArticlesDAO().update(article)}.orElseDo {getArticlesDAO().insert(article)}
Try {getArticlesDAO().update(article)}.orElseDo {getArticlesDAO().insert(article)}
fun insertArticle(article: Article) = getArticlesDAO().forceInsert(article)
fun syncSubscriptions(subscriptions: Subscriptions) {
task {
getSubscriptionsDAO().insertAll(subscriptions)
fun syncSubscriptions(subscriptions: Subscriptions) = task {
val inDatabase = getSubscriptionsDAO().getAll().blockingFirst().map {it.id to it}.toMap()
val inQuery = subscriptions.map {it.id to it}.toMap()
inDatabase to inQuery
}.bind {(inDatabase, inQuery) ->
val suppressTask = task {
val suppressKeys = inDatabase.entries.filter {inQuery[it.key] == null}.map {it.key}
getSubscriptionsDAO().deleteAllById(suppressKeys)
}
task {
getSubscriptionsDAO()
.getAllIds()
.blockingFirst()
.subtract(subscriptions.map {it.id})
.let {getSubscriptionsDAO().deleteAllById(it.toList())}
val updateTask = task {
inQuery.values
.filter {s1 -> !s1.areSimilar(inDatabase[s1.id])}
.forEach {value ->
getSubscriptionsDAO().updateValues(value.id, value.title, value.iconUrl)
if(inDatabase[value.id]?.iconUrl != value.iconUrl) getSubscriptionsDAO().deleteImage(value.id)
}
}
}
val insertTask = task {
val toInsert = inQuery.filter {inDatabase[it.key] == null}.map {it.value}
getSubscriptionsDAO().insertAll(toInsert)
}
all(listOf(suppressTask, updateTask, insertTask), cancelOthersOnError = false)
}.toSuccessVoid()
fun updateSubscriptionCount(id: String, count: Int) = getSubscriptionsDAO().updateCount(id, count)
fun incrementSubscriptionCount(id: String) = getSubscriptionsDAO().incrementCount(id)
fun decrementSubscriptionCount(id: String) = getSubscriptionsDAO().decrementCount(id)
fun insertSubscriptionImage(id: String, bitmap: Bitmap) = getSubscriptionsDAO().insertImage(id, bitmap)
fun getAllSubcriptions() = getSubscriptionsDAO().getAll()
fun getAllSubcriptionsIds() = getSubscriptionsDAO().getAllIds()
fun getAllSubcriptionsWithImageToUpdate() = getSubscriptionsDAO().withImageToUpdate()
companion object {
private val dbName by lazy {
......@@ -58,15 +76,11 @@ abstract class FreshRSSDabatabase: RoomDatabase() {
val instance =
Room
.databaseBuilder(FreshRSSApplication.context, FreshRSSDabatabase::class.java, dbName)
.addMigrations(MIGRATION_1_2)
.addMigrations(*FreshRSSDabatabase_Migrations.build())
.build()
instance.authTokensDelegate.fetchAuthtokensFromDB()
instance
}
private val MIGRATION_1_2 = object: Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) = Unit
}
}
inner class AuthTokensDelegate {
......
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "5aafcaf540b0e800ff3cf01832030d65",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `SID` TEXT NOT NULL, `Auth` TEXT NOT NULL, `login` TEXT NOT NULL, `serverInstance` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "SID",
"columnName": "SID",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "Auth",
"columnName": "Auth",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "login",
"columnName": "login",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverInstance",
"columnName": "serverInstance",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "articles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `href` TEXT NOT NULL, `categories` TEXT NOT NULL, `author` TEXT NOT NULL, `content` TEXT NOT NULL, `streamId` TEXT NOT NULL, `readStatus` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "streamId",
"columnName": "streamId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "readStatus",
"columnName": "readStatus",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"5aafcaf540b0e800ff3cf01832030d65\")"
]
}
}
\ No newline at end of file
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "22063b2221cf4e6c3f3744aecae26e5a",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `SID` TEXT NOT NULL, `Auth` TEXT NOT NULL, `login` TEXT NOT NULL, `serverInstance` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "SID",
"columnName": "SID",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "Auth",
"columnName": "Auth",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "login",
"columnName": "login",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverInstance",
"columnName": "serverInstance",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "articles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `href` TEXT NOT NULL, `categories` TEXT NOT NULL, `author` TEXT NOT NULL, `content` TEXT NOT NULL, `streamId` TEXT NOT NULL, `readStatus` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": true
},