Initial support for local tag search

BREAKING CHANGE: Upgrade database
This commit is contained in:
FooIbar committed 2024-03-06 16:29:37 +08:00
1 parent e1f34a7b1b
commit e0e5a155aa
20 files changed
+761 -245

No files matched your search

@@ -0,0 +1,525 @@
{
"formatVersion": 1,
"database": {
"version": 22,
"identityHash": "f61ae3beef3739c0de7db945d2c9a4a4",
"entities": [
{
"tableName": "GALLERIES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_TAGS` TEXT, `PAGES` INTEGER NOT NULL DEFAULT 0, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "TOKEN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbKey",
"columnName": "THUMB",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "posted",
"columnName": "POSTED",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "UPLOADER",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "RATING",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "simpleTags",
"columnName": "SIMPLE_TAGS",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pages",
"columnName": "PAGES",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "simpleLanguage",
"columnName": "SIMPLE_LANGUAGE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favoriteSlot",
"columnName": "FAVORITE_SLOT",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DOWNLOAD_LABELS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_DOWNLOAD_LABELS_LABEL",
"unique": true,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": []
},
{
"tableName": "DOWNLOADS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "STATE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "legacy",
"columnName": "LEGACY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [
{
"name": "index_DOWNLOADS_LABEL",
"unique": false,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
},
{
"table": "DOWNLOAD_LABELS",
"onDelete": "SET NULL",
"onUpdate": "CASCADE",
"columns": [
"LABEL"
],
"referencedColumns": [
"LABEL"
]
}
]
},
{
"tableName": "DOWNLOAD_DIRNAME",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dirname",
"columnName": "DIRNAME",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "FILTER",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "TEXT",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "ENABLE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HISTORY",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "LOCAL_FAVORITES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "PROGRESS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "PAGE",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "QUICK_SEARCH",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "KEYWORD",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "advanceSearch",
"columnName": "ADVANCE_SEARCH",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minRating",
"columnName": "MIN_RATING",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageFrom",
"columnName": "PAGE_FROM",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageTo",
"columnName": "PAGE_TO",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "GALLERIES",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_BEFORE_UPDATE BEFORE UPDATE ON `GALLERIES` BEGIN DELETE FROM `GALLERIES_FTS` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_BEFORE_DELETE BEFORE DELETE ON `GALLERIES` BEGIN DELETE FROM `GALLERIES_FTS` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_AFTER_UPDATE AFTER UPDATE ON `GALLERIES` BEGIN INSERT INTO `GALLERIES_FTS`(`docid`, `TITLE`, `TITLE_JPN`, `SIMPLE_TAGS`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`, NEW.`SIMPLE_TAGS`); END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_AFTER_INSERT AFTER INSERT ON `GALLERIES` BEGIN INSERT INTO `GALLERIES_FTS`(`docid`, `TITLE`, `TITLE_JPN`, `SIMPLE_TAGS`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`, NEW.`SIMPLE_TAGS`); END"
],
"tableName": "GALLERIES_FTS",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`TITLE` TEXT NOT NULL, `TITLE_JPN` TEXT NOT NULL, `SIMPLE_TAGS` TEXT, content=`GALLERIES`)",
"fields": [
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "simpleTags",
"columnName": "SIMPLE_TAGS",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"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, 'f61ae3beef3739c0de7db945d2c9a4a4')"
]
}
}
@@ -112,7 +112,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
EhDB
}
launch {
DownloadManager.readPagesFromLocal()
DownloadManager.readMetadataFromLocal()
}
launch {
FileUtils.cleanupDirectory(AppConfig.externalCrashDir)
@@ -64,7 +64,7 @@ object EhTagDatabase : CoroutineScope {
var initialized by mutableStateOf(false)
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? {
return tagGroups[prefix]?.get(tag)?.trim()?.takeIf { it.isNotEmpty() }
return tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
}
private fun internalSuggestFlow(
@@ -20,6 +20,8 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@@ -27,6 +29,7 @@ import kotlinx.serialization.Serializable
@Parcelize
@Serializable
@Entity(tableName = "GALLERIES")
@TypeConverters(SimpleTagsConverter::class)
data class BaseGalleryInfo(
@PrimaryKey
@ColumnInfo(name = "GID")
@@ -62,8 +65,8 @@ data class BaseGalleryInfo(
@Ignore
override var rated: Boolean = false,
@Ignore
override var simpleTags: ArrayList<String>? = null,
@ColumnInfo(name = "SIMPLE_TAGS")
override var simpleTags: List<String>? = null,
@ColumnInfo(name = "PAGES", defaultValue = "0")
override var pages: Int = 0,
@@ -88,3 +91,11 @@ data class BaseGalleryInfo(
) : GalleryInfo, Parcelable {
constructor() : this(0, "")
}
class SimpleTagsConverter {
@TypeConverter
fun fromString(value: String) = value.split(", ")
@TypeConverter
fun toString(value: List<String>) = value.joinToString()
}
@@ -45,7 +45,7 @@ data class GalleryDetail(
var torrentUrl: String? = null,
var archiveUrl: String? = null,
var parent: String? = null,
var newerVersions: ArrayList<BaseGalleryInfo> = ArrayList(),
var newerVersions: List<BaseGalleryInfo> = emptyList(),
var visible: String? = null,
var language: String? = null,
var size: String? = null,
@@ -56,8 +56,9 @@ data class GalleryDetail(
val previewPages: Int,
val previewList: List<GalleryPreview>,
) : GalleryInfo by galleryInfo, Parcelable {
override fun generateSLang() {
fun fillInfo() {
val index = LANGUAGES.indexOf(language)
if (index != -1) simpleLanguage = S_LANGS[index]
simpleTags = tags.flatten()
}
}
@@ -30,7 +30,7 @@ interface GalleryInfo {
var disowned: Boolean
var rating: Float
var rated: Boolean
var simpleTags: ArrayList<String>?
var simpleTags: List<String>?
var pages: Int
var thumbWidth: Int
var thumbHeight: Int
@@ -80,7 +80,7 @@ data class ListUrlBuilder(
minRating = q.minRating
pageFrom = q.pageFrom
pageTo = q.pageTo
mNext = q.name.substringAfterLast('@', "").takeIf { it.isNotEmpty() }
mNext = q.name.substringAfterLast('@', "").ifEmpty { null }
}
fun toQuickSearch(name: String): QuickSearch {
@@ -60,6 +60,6 @@ object GalleryApiParser {
@SerialName("filecount")
val pages: Int,
val rating: Float,
val tags: ArrayList<String>,
val tags: List<String>,
)
}
@@ -114,8 +114,8 @@ object GalleryDetailParser {
)
parseDetail(galleryDetail, document, body)
// Generate simpleLanguage for local favorites
galleryDetail.generateSLang()
// Fill info for database
galleryDetail.fillInfo()
return galleryDetail
}
@@ -231,16 +231,10 @@ object GalleryDetailParser {
// newer version
d.getElementById("gnd")?.run {
val dates = PATTERN_NEWER_DATE.findAll(body).map { it.groupValues[1] }.toList()
select("a").forEachIndexed { index, element ->
gd.newerVersions = select("a").mapIndexedNotNull { index, element ->
val result = GalleryDetailUrlParser.parse(element.attr("href"))
if (result != null) {
val gi = BaseGalleryInfo(
result.gid,
result.token,
element.text().trim(),
posted = dates[index],
)
gd.newerVersions.add(gi)
result?.run {
BaseGalleryInfo(gid, token, element.text().trim(), posted = dates[index])
}
}
}
@@ -2,206 +2,37 @@ package com.hippo.ehviewer.dao
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.RenameColumn
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
@DeleteTable(tableName = "BOOKMARKS")
class DropBookMarkDao : AutoMigrationSpec
@RenameColumn(tableName = "HISTORY", fromColumnName = "MODE", toColumnName = "FAVORITE_SLOT")
class HistoryMigration : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.execSQL("UPDATE HISTORY SET FAVORITE_SLOT = FAVORITE_SLOT - 2")
}
}
class AddPositionColumn : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"QUICK_SEARCH",
"DOWNLOAD_LABELS",
"DOWNLOADS",
)
needMigrationTables.forEach { table ->
// TODO: Rewrite this with row_number() when min sdk is 30 (SQLite 3.28.0)
db.execSQL("UPDATE $table SET POSITION = (SELECT COUNT(*) FROM $table T WHERE T.TIME < $table.TIME)")
}
}
}
@DeleteColumn(tableName = "QUICK_SEARCH", columnName = "TIME")
@DeleteColumn(tableName = "DOWNLOAD_LABELS", columnName = "TIME")
class DropTimeColumn : AutoMigrationSpec
class AddGalleryTable : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"HISTORY",
"DOWNLOADS",
"LOCAL_FAVORITES",
)
needMigrationTables.forEachIndexed { index, table ->
db.execSQL(
"INSERT OR IGNORE INTO GALLERIES " +
"SELECT GID, TOKEN, TITLE, TITLE_JPN, THUMB, CATEGORY, POSTED, UPLOADER, RATING, SIMPLE_LANGUAGE, " +
"${if (index == 0) "FAVORITE_SLOT" else NOT_FAVORITED} FROM $table",
)
}
}
}
@DeleteColumn(tableName = "HISTORY", columnName = "TOKEN")
@DeleteColumn(tableName = "HISTORY", columnName = "TITLE")
@DeleteColumn(tableName = "HISTORY", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "HISTORY", columnName = "THUMB")
@DeleteColumn(tableName = "HISTORY", columnName = "CATEGORY")
@DeleteColumn(tableName = "HISTORY", columnName = "POSTED")
@DeleteColumn(tableName = "HISTORY", columnName = "UPLOADER")
@DeleteColumn(tableName = "HISTORY", columnName = "RATING")
@DeleteColumn(tableName = "HISTORY", columnName = "SIMPLE_LANGUAGE")
@DeleteColumn(tableName = "HISTORY", columnName = "FAVORITE_SLOT")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TOKEN")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TITLE")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "THUMB")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "CATEGORY")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "POSTED")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "UPLOADER")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "RATING")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "SIMPLE_LANGUAGE")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TOKEN")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TITLE")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "THUMB")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "CATEGORY")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "POSTED")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "UPLOADER")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "RATING")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "SIMPLE_LANGUAGE")
class GalleryTableMigration : AutoMigrationSpec
class ThumbKeyMigration : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"DOWNLOADS",
"HISTORY",
"LOCAL_FAVORITES",
)
val prefixToRemove = arrayOf(
"https://ehgt.org/",
"https://s.exhentai.org/t/",
"https://exhentai.org/t/",
)
needMigrationTables.forEach { table ->
prefixToRemove.forEach { prefix ->
db.execSQL("UPDATE $table SET thumb = SUBSTR(thumb ,LENGTH('$prefix') + 1) WHERE thumb LIKE '$prefix%'")
}
}
}
}
@RenameColumn(tableName = "DOWNLOADS", fromColumnName = "POSITION", toColumnName = "TIME")
class DownloadsMigration : AutoMigrationSpec
class GalleryFtsMigration : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.execSQL("INSERT INTO GalleryFts(GalleryFts) VALUES('rebuild')")
}
}
@Database(
entities = [
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class,
Filter::class, HistoryInfo::class, LocalFavoriteInfo::class, ProgressInfo::class, QuickSearch::class,
GalleryFts::class,
GalleryEntityFts::class,
],
version = 21,
version = 22,
exportSchema = true,
autoMigrations = [
AutoMigration(
from = 4,
to = 5,
spec = ThumbKeyMigration::class,
),
AutoMigration(
from = 5,
to = 6,
),
AutoMigration(
from = 6,
to = 7,
spec = DropBookMarkDao::class,
),
AutoMigration(
from = 7,
to = 8,
),
AutoMigration(
from = 8,
to = 9,
),
AutoMigration(
from = 9,
to = 10,
spec = HistoryMigration::class,
),
AutoMigration(
from = 10,
to = 11,
spec = AddPositionColumn::class,
),
AutoMigration(
from = 11,
to = 12,
spec = DropTimeColumn::class,
),
AutoMigration(
from = 12,
to = 13,
spec = AddGalleryTable::class,
),
AutoMigration(
from = 13,
to = 14,
spec = GalleryTableMigration::class,
),
AutoMigration(
from = 14,
to = 15,
),
AutoMigration(
from = 15,
to = 16,
),
AutoMigration(
from = 16,
to = 17,
),
AutoMigration(
from = 17,
to = 18,
),
AutoMigration(
from = 18,
to = 19,
spec = DownloadsMigration::class,
),
AutoMigration(
from = 19,
to = 20,
),
AutoMigration(
from = 20,
to = 21,
spec = GalleryFtsMigration::class,
),
AutoMigration(from = 4, to = 5, spec = Schema4to5::class),
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7, spec = Schema6to7::class),
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10, spec = Schema9to10::class),
AutoMigration(from = 10, to = 11, spec = Schema10to11::class),
AutoMigration(from = 11, to = 12, spec = Schema11to12::class),
AutoMigration(from = 12, to = 13, spec = Schema12to13::class),
AutoMigration(from = 13, to = 14, spec = Schema13to14::class),
AutoMigration(from = 14, to = 15),
AutoMigration(from = 15, to = 16),
AutoMigration(from = 16, to = 17),
AutoMigration(from = 17, to = 18),
AutoMigration(from = 18, to = 19, spec = Schema18to19::class),
AutoMigration(from = 19, to = 20),
AutoMigration(from = 20, to = 21, spec = Schema20to21::class),
AutoMigration(from = 21, to = 22, spec = Schema21to22::class),
],
)
@TypeConverters(FilterModeConverter::class)
@@ -221,10 +52,7 @@ abstract class EhDatabase : RoomDatabase() {
entities = [Search::class],
version = 2,
autoMigrations = [
AutoMigration(
from = 1,
to = 2,
),
AutoMigration(from = 1, to = 2),
],
)
abstract class SearchDatabase : RoomDatabase() {
@@ -0,0 +1,117 @@
package com.hippo.ehviewer.dao
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.RenameColumn
import androidx.room.RenameTable
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
class Schema4to5 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"DOWNLOADS",
"HISTORY",
"LOCAL_FAVORITES",
)
val prefixToRemove = arrayOf(
"https://ehgt.org/",
"https://s.exhentai.org/t/",
"https://exhentai.org/t/",
)
needMigrationTables.forEach { table ->
prefixToRemove.forEach { prefix ->
db.execSQL("UPDATE $table SET thumb = SUBSTR(thumb ,LENGTH('$prefix') + 1) WHERE thumb LIKE '$prefix%'")
}
}
}
}
@DeleteTable(tableName = "BOOKMARKS")
class Schema6to7 : AutoMigrationSpec
@RenameColumn(tableName = "HISTORY", fromColumnName = "MODE", toColumnName = "FAVORITE_SLOT")
class Schema9to10 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.execSQL("UPDATE HISTORY SET FAVORITE_SLOT = FAVORITE_SLOT - 2")
}
}
class Schema10to11 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"QUICK_SEARCH",
"DOWNLOAD_LABELS",
"DOWNLOADS",
)
needMigrationTables.forEach { table ->
// TODO: Rewrite this with row_number() when min sdk is 30 (SQLite 3.28.0)
db.execSQL("UPDATE $table SET POSITION = (SELECT COUNT(*) FROM $table T WHERE T.TIME < $table.TIME)")
}
}
}
@DeleteColumn(tableName = "QUICK_SEARCH", columnName = "TIME")
@DeleteColumn(tableName = "DOWNLOAD_LABELS", columnName = "TIME")
class Schema11to12 : AutoMigrationSpec
class Schema12to13 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"HISTORY",
"DOWNLOADS",
"LOCAL_FAVORITES",
)
needMigrationTables.forEachIndexed { index, table ->
db.execSQL(
"INSERT OR IGNORE INTO GALLERIES " +
"SELECT GID, TOKEN, TITLE, TITLE_JPN, THUMB, CATEGORY, POSTED, UPLOADER, RATING, SIMPLE_LANGUAGE, " +
"${if (index == 0) "FAVORITE_SLOT" else NOT_FAVORITED} FROM $table",
)
}
}
}
@DeleteColumn(tableName = "HISTORY", columnName = "TOKEN")
@DeleteColumn(tableName = "HISTORY", columnName = "TITLE")
@DeleteColumn(tableName = "HISTORY", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "HISTORY", columnName = "THUMB")
@DeleteColumn(tableName = "HISTORY", columnName = "CATEGORY")
@DeleteColumn(tableName = "HISTORY", columnName = "POSTED")
@DeleteColumn(tableName = "HISTORY", columnName = "UPLOADER")
@DeleteColumn(tableName = "HISTORY", columnName = "RATING")
@DeleteColumn(tableName = "HISTORY", columnName = "SIMPLE_LANGUAGE")
@DeleteColumn(tableName = "HISTORY", columnName = "FAVORITE_SLOT")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TOKEN")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TITLE")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "THUMB")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "CATEGORY")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "POSTED")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "UPLOADER")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "RATING")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "SIMPLE_LANGUAGE")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TOKEN")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TITLE")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "THUMB")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "CATEGORY")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "POSTED")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "UPLOADER")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "RATING")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "SIMPLE_LANGUAGE")
class Schema13to14 : AutoMigrationSpec
@RenameColumn(tableName = "DOWNLOADS", fromColumnName = "POSITION", toColumnName = "TIME")
class Schema18to19 : AutoMigrationSpec
class Schema20to21 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.execSQL("INSERT INTO GalleryFts(GalleryFts) VALUES('rebuild')")
}
}
// Virtual tables can't be altered, so we use rename to recreate it
@RenameTable(fromTableName = "GalleryFts", toTableName = "GALLERIES_FTS")
class Schema21to22 : AutoMigrationSpec
@@ -6,11 +6,14 @@ import androidx.room.Fts4
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@Fts4(contentEntity = BaseGalleryInfo::class)
@Entity
class GalleryFts(
@Entity(tableName = "GALLERIES_FTS")
class GalleryEntityFts(
@ColumnInfo(name = "TITLE")
val title: String,
@ColumnInfo(name = "TITLE_JPN")
val titleJpn: String,
@ColumnInfo(name = "SIMPLE_TAGS")
val simpleTags: String?,
)
@@ -18,7 +18,7 @@ interface HistoryDao {
@Query(
"""SELECT GALLERIES.* FROM HISTORY JOIN GALLERIES USING(GID)
JOIN GalleryFts ON GALLERIES.rowid = docid WHERE GalleryFts MATCH :title ORDER BY TIME DESC""",
JOIN GALLERIES_FTS ON GALLERIES.rowid = docid WHERE GALLERIES_FTS MATCH :title ORDER BY TIME DESC""",
)
fun joinListLazy(title: String): PagingSource<Int, BaseGalleryInfo>
@@ -22,7 +22,7 @@ interface LocalFavoritesDao {
@Query(
"""SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID)
JOIN GalleryFts ON GALLERIES.rowid = docid WHERE GalleryFts MATCH :title ORDER BY TIME DESC""",
JOIN GALLERIES_FTS ON GALLERIES.rowid = docid WHERE GALLERIES_FTS MATCH :title ORDER BY TIME DESC""",
)
fun joinListLazy(title: String): PagingSource<Int, BaseGalleryInfo>
@@ -39,6 +39,7 @@ import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
import com.hippo.ehviewer.spider.putToDownloadDir
import com.hippo.ehviewer.spider.readComicInfo
import com.hippo.ehviewer.spider.readCompatFromUniFile
import com.hippo.ehviewer.spider.toSimpleTags
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.insertWith
import com.hippo.ehviewer.util.mapNotNull
@@ -529,17 +530,23 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
}
suspend fun readPagesFromLocal() {
val list = allInfoList.filter { it.pages == 0 }.parMapNotNull(concurrency = 5) { info ->
suspend fun readMetadataFromLocal() {
val list = allInfoList.filter { it.pages == 0 || it.simpleTags == null }.parMapNotNull(concurrency = 5) { info ->
info.downloadDir?.run {
val pages = findFile(SPIDER_INFO_FILENAME)?.let {
readCompatFromUniFile(it)?.pages
} ?: findFile(COMIC_INFO_FILE)?.let {
readComicInfo(it)?.pageCount
}
pages?.let {
info.pages = it
val comicInfo = findFile(COMIC_INFO_FILE)?.let { readComicInfo(it) }
if (comicInfo != null) {
info.pages = comicInfo.pageCount
info.simpleTags = comicInfo.toSimpleTags()
info.galleryInfo
} else if (info.pages == 0) {
findFile(SPIDER_INFO_FILENAME)?.let {
readCompatFromUniFile(it)?.run {
info.pages = pages
info.galleryInfo
}
}
} else {
null
}
}
}
@@ -3,14 +3,20 @@ package com.hippo.ehviewer.spider
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.SimpleTagsConverter
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.unifile.UniFile
import com.hippo.unifile.openInputStream
import com.hippo.unifile.openOutputStream
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.newGenericWriter
@@ -39,14 +45,15 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
with(TagNamespace) {
when (this@getComicInfo) {
is GalleryDetail -> tags.forEach { tagList ->
val list = tagList.filterNot { it == TAG_ORIGINAL || it.startsWith('_') }
when (val ns = TagNamespace(tagList.groupName)) {
Artist, Cosplayer -> artists.addAll(tagList)
Group -> groups.addAll(tagList)
Character -> characters.addAll(tagList)
Parody -> tagList.forEach { tag -> if (tag != TAG_ORIGINAL) parodies.add(tag) }
Other -> otherTags.addAll(tagList)
Artist, Cosplayer -> artists.addAll(list)
Group -> groups.addAll(list)
Character -> characters.addAll(list)
Parody -> parodies.addAll(list)
Other -> otherTags.addAll(list)
Female, Male, Mixed -> ns.toPrefix()?.let { prefix ->
tagList.forEach { tag -> otherTags.add("$prefix:$tag") }
list.forEach { tag -> otherTags.add("$prefix:$tag") }
}
else -> Unit
@@ -70,18 +77,26 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
return ComicInfo(
series = title,
alternateSeries = titleJpn.takeUnless { it.isNullOrBlank() },
writer = groups.takeUnless { it.isEmpty() }?.joinToString(),
penciller = artists.takeUnless { it.isEmpty() }?.joinToString(),
genre = otherTags.takeUnless { it.isEmpty() }?.joinToString(),
writer = groups.ifEmpty { null },
penciller = artists.ifEmpty { null },
genre = otherTags.ifEmpty { null },
web = EhUrl.getGalleryDetailUrl(gid, token),
pageCount = pages,
languageISO = simpleLanguage?.lowercase(),
characters = characters.takeUnless { it.isEmpty() }?.joinToString(),
teams = parodies.takeUnless { it.isEmpty() }?.joinToString(),
characters = characters.ifEmpty { null },
teams = parodies.ifEmpty { null },
communityRating = "%.1f".format(rating),
)
}
fun ComicInfo.toSimpleTags() = listOfNotNull(
writer,
penciller,
genre,
characters,
teams,
).flatten().ifEmpty { null }
fun ComicInfo.write(file: UniFile) {
file.openOutputStream().use {
it.channel.truncate(0)
@@ -103,6 +118,16 @@ fun readComicInfo(file: UniFile): ComicInfo? = runCatching {
}
}.getOrNull()
@Suppress("ktlint:standard:annotation")
typealias SimpleTags = @Serializable(SimpleTagsSerializer::class) List<String>
object SimpleTagsSerializer : KSerializer<SimpleTags> {
private val converter = SimpleTagsConverter()
override val descriptor = PrimitiveSerialDescriptor("SimpleTags", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder) = converter.fromString(decoder.decodeString())
override fun serialize(encoder: Encoder, value: SimpleTags) = encoder.encodeString(converter.toString(value))
}
@Serializable
data class ComicInfo(
@XmlElement
@@ -115,15 +140,15 @@ data class ComicInfo(
@XmlElement
@SerialName("Writer")
val writer: String?,
val writer: SimpleTags?,
@XmlElement
@SerialName("Penciller")
val penciller: String?,
val penciller: SimpleTags?,
@XmlElement
@SerialName("Genre")
val genre: String?,
val genre: SimpleTags?,
@XmlElement
@SerialName("Web")
@@ -139,11 +164,11 @@ data class ComicInfo(
@XmlElement
@SerialName("Characters")
val characters: String?,
val characters: SimpleTags?,
@XmlElement
@SerialName("Teams")
val teams: String?,
val teams: SimpleTags?,
@XmlElement
@SerialName("CommunityRating")
@@ -188,7 +188,7 @@ class SpiderDen(val info: GalleryInfo) {
private suspend fun saveFromHttpResponse(index: Int, response: HttpResponse): Boolean {
val url = response.request.url.toString()
val extension = MimeTypeMap.getFileExtensionFromUrl(url).takeUnless { it.isEmpty() } ?: "jpg"
val extension = MimeTypeMap.getFileExtensionFromUrl(url).ifEmpty { "jpg" }
return saveResponseMeta(index, extension) { outFile ->
outFile.openOutputStream().use {
val chan = it.channel.apply { truncate(0) }
@@ -14,4 +14,7 @@ data class DownloadsFilterState(
fun DownloadsFilterState.take(info: DownloadInfo) =
(label == "" || info.label == label) && (state == -1 || info.state == state) &&
with(info) { title.containsIgnoreCase(keyword) || titleJpn.containsIgnoreCase(keyword) }
with(info) {
title.containsIgnoreCase(keyword) || titleJpn.containsIgnoreCase(keyword) ||
simpleTags?.any { it.containsIgnoreCase(keyword) } ?: false
}
@@ -541,6 +541,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
GalleryPageUrlSuggestion(gid, pToken, page)
}
},
tagNamespace = true,
searchBarOffsetY = { searchBarOffsetY },
trailingIcon = {
val sheetState = LocalSideSheetState.current
@@ -104,6 +104,7 @@ fun SearchBarScreen(
onSearchHidden: () -> Unit,
refreshState: PullToRefreshState? = null,
suggestionProvider: SuggestionProvider? = null,
tagNamespace: Boolean = false,
searchBarOffsetY: () -> Int,
trailingIcon: @Composable () -> Unit,
filter: @Composable (() -> Unit)? = null,
@@ -125,7 +126,7 @@ fun SearchBarScreen(
val query = searchFieldState.text.toString()
var keywords = query.substringBeforeLast(' ', "")
if (keywords.isNotEmpty()) keywords += ' '
keywords += wrapTagKeyword(keyword)
keywords += if (tagNamespace) wrapTagKeyword(keyword) else keyword.substringAfter(':')
if (!keywords.endsWith(':')) keywords += ' '
searchFieldState.setTextAndPlaceCursorAtEnd(keywords)
}