+}
\ No newline at end of file
diff --git a/core/src/main/java/com/flamyoad/tsukiviewer/core/parser/HenNexusParser.kt b/core/src/main/java/com/flamyoad/tsukiviewer/core/parser/HenNexusParser.kt
new file mode 100644
index 0000000..2c17a28
--- /dev/null
+++ b/core/src/main/java/com/flamyoad/tsukiviewer/core/parser/HenNexusParser.kt
@@ -0,0 +1,117 @@
+package com.flamyoad.tsukiviewer.core.parser
+
+import com.flamyoad.tsukiviewer.core.network.Metadata
+import com.flamyoad.tsukiviewer.core.network.Result
+import com.flamyoad.tsukiviewer.core.network.Tags
+import com.flamyoad.tsukiviewer.core.network.Title
+import org.jsoup.Jsoup
+import java.util.*
+
+class HenNexusParser {
+
+ private val baseUrl = "https://hentainexus.com"
+
+ // Returns the link of the first item in result
+ fun getFirstItemInList(html: String): String {
+ val document = Jsoup.parse(html)
+
+ val itemList = document.selectFirst(".container > .columns") ?: return ""
+
+ val firstItem = itemList.selectFirst("div") ?: return ""
+
+ // Example value: "/view/8808"
+ val relativeLink = firstItem.select("a").attr("href")
+
+ if (relativeLink.isNotBlank()) {
+ return relativeLink
+ } else {
+ return ""
+ }
+ }
+
+ // Returns the title, tags found in+ the HenNexus doujin
+ //todo: Surround this method with try/catch..
+ fun parseItem(html: String): Metadata {
+ val document = Jsoup.parse(html)
+
+ val englishTitleContainer = document.selectFirst("h1.title")
+
+ // Means no result is found
+ if (englishTitleContainer == null) {
+ return Metadata(emptyList(), false)
+ }
+
+ val englishTitle = document.selectFirst("h1.title").text() ?: ""
+
+ val table = document.selectFirst("table.view-page-details")
+ val tableRows = table.select("tr")
+
+ var artistName = ""
+ var language = ""
+ var magazine = ""
+ var parody = ""
+ var publisher = ""
+
+ for (row in tableRows) {
+ // https://stackoverflow.com/questions/7985791/why-jsoup-cannot-select-td-element
+ // Jsoup is a HTML5 compliant parser. It cannot detect a that is not inside a
+ val correctedHtml = ""
+ val correctedRow = Jsoup.parse(correctedHtml)
+
+ val rowLabel = correctedRow.selectFirst(".viewcolumn").text()
+ val rowContainer = correctedRow.selectFirst("a")
+
+ // 'Pages' row does not have link and will return null
+ if (rowContainer != null) {
+ // Make the string lowercase to be consistent with NHentai e.g. Big Breasts -> big breasts
+ val rowValue = rowContainer.text().toLowerCase(Locale.ROOT)
+ when (rowLabel) {
+ "Artist" -> artistName = rowValue
+ "Language" -> language = rowValue
+ "Magazine" -> magazine = rowValue
+ "Parody" -> parody = rowValue
+ "Publisher" -> publisher = rowValue
+ }
+ }
+ }
+
+ val spans = tableRows.select("span.tag")
+
+ val tagList =
+ spans
+ .filter { tag -> tag.text().isNotBlank() }
+ .map { Tags(id = 0, type = "tag", name = it.text(), count = 0, url = "") }
+ .toMutableList()
+
+ if (artistName.isNotBlank()) {
+ tagList.add(Tags(id = 0, type = "artist", name = artistName, count = 0, url = ""))
+ }
+
+ if (language.isNotBlank()) {
+ tagList.add(Tags(id = 0, type = "language", name = language, count = 0, url = ""))
+ }
+
+ if (magazine.isNotBlank()) {
+ tagList.add(Tags(id = 0, type = "group", name = magazine, count = 0, url = ""))
+ }
+
+ if (parody.isNotBlank()) {
+ tagList.add(Tags(id = 0, type = "parody", name = parody, count = 0, url = ""))
+ }
+
+ if (publisher.isNotBlank()) {
+ tagList.add(Tags(id = 0, type = "group", name = publisher, count = 0, url = ""))
+ }
+
+ val result = Result(
+ nukeCode = 0,
+ title = Title(english = englishTitle, japanese = "", pretty = englishTitle),
+ scanlator = "",
+ upload_date = 0,
+ tags = tagList
+ )
+
+ val metadata = Metadata(listOf(result))
+ return metadata
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/BookmarkRepository.kt b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/BookmarkRepository.kt
new file mode 100644
index 0000000..2cb7974
--- /dev/null
+++ b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/BookmarkRepository.kt
@@ -0,0 +1,227 @@
+package com.flamyoad.tsukiviewer.core.repository
+
+import android.util.Log
+import androidx.lifecycle.LiveData
+import androidx.room.withTransaction
+import com.flamyoad.tsukiviewer.core.db.AppDatabase
+import com.flamyoad.tsukiviewer.core.db.dao.BookmarkGroupDao
+import com.flamyoad.tsukiviewer.core.db.dao.BookmarkItemDao
+import com.flamyoad.tsukiviewer.core.model.BookmarkGroup
+import com.flamyoad.tsukiviewer.core.model.BookmarkItem
+import com.flamyoad.tsukiviewer.core.model.Doujin
+import org.threeten.bp.Instant
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BookmarkRepository @Inject constructor(
+ private val db: AppDatabase,
+ val groupDao: BookmarkGroupDao,
+ val itemDao: BookmarkItemDao
+) {
+ companion object {
+ const val DEFAULT_BOOKMARK_GROUP = "Default Bookmark Group"
+ }
+
+ fun getAllItems(): LiveData> {
+ return itemDao.selectAll()
+ }
+
+ fun getAllItems(group: BookmarkGroup): LiveData> {
+ return itemDao.from(group.name)
+ }
+
+ suspend fun getAllItemsFrom(group: BookmarkGroup): List {
+ return itemDao.selectFrom(group.name)
+ }
+
+ suspend fun getAllItemsFrom(groupName: String): List {
+ return itemDao.selectFrom(groupName)
+ }
+
+ fun getGroup(name: String): LiveData {
+ return groupDao.get(name)
+ }
+
+ fun getAllGroups(): LiveData> {
+ return groupDao.getAll()
+ }
+
+ fun groupNameExists(name: String): LiveData {
+ return groupDao.exists(name)
+ }
+
+ suspend fun getAllGroupsFrom(absolutePath: File): List {
+ val collections = groupDao.getAllBlocking()
+ val names = groupDao.getCollectionNamesFrom(absolutePath)
+
+ for (collection in collections) {
+ if (collection.name in names) {
+ collection.isTicked = true
+ }
+ }
+ return collections
+ }
+
+ suspend fun changeGroupName(oldName: String, newName: String) {
+ return groupDao.changeName(oldName, newName)
+ }
+
+ suspend fun removeGroup(group: BookmarkGroup) {
+ groupDao.delete(group)
+ }
+
+ suspend fun removeGroup(name: String) {
+ groupDao.delete(name)
+ }
+
+ suspend fun getAllGroupsBlocking(): List {
+ return groupDao.getAllBlocking()
+ }
+
+ suspend fun insertGroup(collection: BookmarkGroup) {
+ groupDao.insert(collection)
+ }
+
+ suspend fun insertItem(item: BookmarkItem) {
+ itemDao.insert(item)
+ }
+
+ suspend fun moveItemsTo(group: BookmarkGroup, itemsToBeMoved: List): Int {
+ val movedItems = db.withTransaction {
+ itemDao.delete(itemsToBeMoved)
+
+ val itemList = itemsToBeMoved.map { x ->
+ BookmarkItem(
+ id = null,
+ absolutePath = x.absolutePath,
+ parentName = group.name,
+ dateAdded = x.dateAdded
+ )
+ }
+ return@withTransaction itemDao.insert(itemList)
+ }
+ return movedItems.size
+ }
+
+ // Returns: Snackbar message to be shown to user indicating the number of insert and delete
+ suspend fun wipeAndInsertNew(
+ absolutePath: File,
+ hashMap: HashMap
+ ): String {
+ val namesOfCollectionsToRemoveFrom = hashMap
+ .filter { kvp -> kvp.value == false }
+ .map { kvp -> kvp.key }
+
+ val dateAdded = Instant.now().toEpochMilli()
+
+ val itemsToInsert = hashMap
+ .filter { kvp -> kvp.value == true }
+ .map { kvp ->
+ BookmarkItem(
+ id = null,
+ absolutePath = absolutePath,
+ parentName = kvp.key,
+ dateAdded = dateAdded
+ )
+ }
+
+ return db.withTransaction {
+ try {
+ var insertCount = 0
+ for (item in itemsToInsert) {
+ if (itemDao.exists(item.absolutePath, item.parentName)) {
+ continue
+ } else {
+ val insertedId = itemDao.insert(item)
+ if (insertedId > 0) {
+ insertCount++
+ }
+ }
+ }
+
+ var deleteCount = 0
+ for (name in namesOfCollectionsToRemoveFrom) {
+ val count = itemDao.delete(absolutePath, name)
+ deleteCount += count
+ }
+
+ // Example message: Added into 1 collection. Removed from 1 collection.
+ val stringBuilder = StringBuilder()
+
+ if (insertCount > 0) {
+ stringBuilder.append("Added into ${insertCount} ${getNoun(insertCount)}. ")
+ }
+
+ if (deleteCount > 0) {
+ stringBuilder.append("Removed from ${deleteCount} ${getNoun(deleteCount)}")
+ }
+
+ return@withTransaction stringBuilder.toString()
+
+ } catch (e: Exception) {
+ Log.e("db", e.message ?: "Unknown error")
+ e.printStackTrace()
+ return@withTransaction "Failed to add or remove current doujin"
+ }
+ }
+ }
+
+ suspend fun insertAllItems(doujinList: List, groupNames: List): String {
+ val dateAdded = Instant.now().toEpochMilli()
+
+ val itemsToInsert = groupNames.flatMap { groupName ->
+ doujinList.map { doujin ->
+ BookmarkItem(
+ id = null,
+ absolutePath = doujin.path,
+ parentName = groupName,
+ dateAdded = dateAdded
+ )
+ }
+ }
+
+ return db.withTransaction {
+ var insertCount = 0
+ for (item in itemsToInsert) {
+ if (itemDao.exists(item.absolutePath, item.parentName)) {
+ continue
+ } else {
+ val insertedId = itemDao.insert(item)
+ if (insertedId > 0) {
+ insertCount++
+ }
+ }
+ }
+
+ if (insertCount == 0) {
+ return@withTransaction "No items are bookmarked"
+ }
+
+ val builder = StringBuilder()
+
+ when {
+ insertCount == 1 -> builder.append("$insertCount item has been bookmarked. ")
+ insertCount > 1 -> builder.append("$insertCount items have been bookmarked. ")
+ }
+
+ val ignoreCount = doujinList.size - insertCount
+ when {
+ ignoreCount == 1 -> builder.append("$ignoreCount duplicate item ignored")
+ ignoreCount > 1 -> builder.append("$ignoreCount duplicate items ignored")
+ }
+
+ return@withTransaction builder.toString()
+ }
+ }
+
+ private fun getNoun(number: Int): String {
+ if (number > 1) {
+ return "collections"
+ } else {
+ return "collection"
+ }
+ }
+}
+
diff --git a/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/CollectionRepository.kt b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/CollectionRepository.kt
new file mode 100644
index 0000000..5621f1f
--- /dev/null
+++ b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/CollectionRepository.kt
@@ -0,0 +1,167 @@
+package com.flamyoad.tsukiviewer.core.repository
+
+import androidx.lifecycle.LiveData
+import androidx.room.withTransaction
+import com.flamyoad.tsukiviewer.core.db.AppDatabase
+import com.flamyoad.tsukiviewer.core.db.dao.CollectionCriteriaDao
+import com.flamyoad.tsukiviewer.core.db.dao.CollectionDao
+import com.flamyoad.tsukiviewer.core.db.dao.CollectionDoujinDao
+import com.flamyoad.tsukiviewer.core.model.*
+import com.flamyoad.tsukiviewer.core.model.Collection
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class CollectionRepository @Inject constructor(
+ private val db: AppDatabase,
+ private val collectionDao: CollectionDao,
+ private val criteriaDao: CollectionCriteriaDao,
+ private val collectionDoujinDao: CollectionDoujinDao
+) {
+
+ suspend fun get(id: Long): Collection {
+ return collectionDao.getBlocking(id)
+ }
+
+ suspend fun getAll(): List {
+ return collectionDao.getAllBlocking()
+ }
+
+ fun getAllWithCriterias(): LiveData> {
+ return collectionDao.getAllWithCriterias()
+ }
+
+ fun getAllWithCriterias(keyword: String): LiveData> {
+ return collectionDao.getAllWithCriterias(keyword)
+ }
+
+ suspend fun insert(collection: Collection, criterias: List) {
+ db.withTransaction {
+ val collectionId = collectionDao.insert(collection)
+
+ // If editing existing collection, wipe all previous criterias before inserting new ones
+ collection.id?.let {
+ criteriaDao.delete(it)
+ collectionDao.deleteThumbnail(it)
+ }
+
+ // Fill in criterias with collectionId, which is impossible to get before first inserting the collection
+ val completedCriterias = criterias.map { criteria ->
+ CollectionCriteria(
+ id = null,
+ collectionId = collectionId,
+ type = criteria.type,
+ value = criteria.value,
+ valueName = criteria.valueName
+ )
+ }
+
+ for (criteria in completedCriterias) {
+ criteriaDao.insert(criteria)
+ }
+ }
+ }
+
+ suspend fun delete(collectionId: Long) {
+ db.withTransaction {
+ collectionDao.delete(collectionId)
+ criteriaDao.delete(collectionId)
+ }
+ }
+
+ // It's ok to use INSERT for new/old because onConflictStrategy is REPLACE
+ suspend fun update(criterias: List) {
+ db.withTransaction {
+ for (criteria in criterias) {
+ criteriaDao.insert(criteria)
+ }
+ }
+ }
+
+ suspend fun updateThumbnail(collectionId: Long?, file: File) {
+ if (collectionId == null) return
+ collectionDao.updateThumbnail(collectionId, file)
+ }
+
+ suspend fun getTitles(id: Long): List {
+ return criteriaDao.getTitlesBlocking(id)
+ }
+
+ suspend fun getIncludedTags(id: Long): List {
+ return criteriaDao.getIncludedTagsBlocking(id)
+ }
+
+ suspend fun getExcludedTags(id: Long): List {
+ return criteriaDao.getExcludedTagsBlocking(id)
+ }
+
+ suspend fun getDirectories(id: Long): List {
+ return criteriaDao.getDirectoriesBlocking(id)
+ }
+
+ suspend fun searchIncludedOrExcludedOr(
+ includedTags: List,
+ excludedTags: List
+ ): List {
+ val includedTagsId = includedTags.map { tag -> tag.tagId ?: -1 }
+ val excludedTagsId = excludedTags.map { tag -> tag.tagId ?: -1 }
+
+ if (includedTags.isEmpty())
+ return collectionDoujinDao.searchExcludedOr(excludedTagsId)
+
+ if (excludedTags.isEmpty())
+ return collectionDoujinDao.searchIncludedOr(includedTagsId)
+
+ return collectionDoujinDao.searchIncludedOrExcludedOr(includedTagsId, excludedTagsId)
+ }
+
+ suspend fun searchIncludedOrExcludedAnd(
+ includedTags: List,
+ excludedTags: List
+ ): List {
+ val includedTagsId = includedTags.map { tag -> tag.tagId ?: -1 }
+ val excludedTagsId = excludedTags.map { tag -> tag.tagId ?: -1 }
+
+ if (includedTags.isEmpty())
+ return collectionDoujinDao.searchExcludedAnd(excludedTagsId, excludedTagsId.size)
+
+ if (excludedTags.isEmpty())
+ return collectionDoujinDao.searchIncludedOr(includedTagsId)
+
+ return collectionDoujinDao.searchIncludedOrExcludedAnd(includedTagsId, excludedTagsId, excludedTags.size)
+ }
+
+ suspend fun searchIncludedAndExcludedOr(
+ includedTags: List,
+ excludedTags: List
+ ): List {
+ val includedTagsId = includedTags.map { tag -> tag.tagId ?: -1 }
+ val excludedTagsId = excludedTags.map { tag -> tag.tagId ?: -1 }
+
+ if (includedTags.isEmpty())
+ return collectionDoujinDao.searchExcludedOr(excludedTagsId)
+
+ if (excludedTags.isEmpty())
+ return collectionDoujinDao.searchIncludedAnd(includedTagsId, includedTagsId.size)
+
+ return collectionDoujinDao.searchIncludedAndExcludedOr(includedTagsId, excludedTagsId, includedTags.size)
+ }
+
+ suspend fun searchIncludedAndExcludedAnd(
+ includedTags: List,
+ excludedTags: List
+ ): List {
+ val includedTagsId = includedTags.map { tag -> tag.tagId ?: -1 }
+ val excludedTagsId = excludedTags.map { tag -> tag.tagId ?: -1 }
+
+ if (includedTags.isEmpty())
+ return collectionDoujinDao.searchExcludedAnd(excludedTagsId, excludedTagsId.size)
+
+ if (excludedTags.isEmpty())
+ return collectionDoujinDao.searchIncludedAnd(includedTagsId, includedTagsId.size)
+
+ return collectionDoujinDao.searchIncludedAndExcludedAnd(includedTagsId, excludedTagsId, includedTags.size, excludedTags.size)
+ }
+
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/DoujinRepository.kt b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/DoujinRepository.kt
new file mode 100644
index 0000000..421419a
--- /dev/null
+++ b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/DoujinRepository.kt
@@ -0,0 +1,171 @@
+package com.flamyoad.tsukiviewer.core.repository
+
+import android.app.Application
+import android.content.ContentResolver
+import android.provider.MediaStore
+import androidx.core.content.ContentResolverCompat
+import androidx.core.net.toUri
+import com.flamyoad.tsukiviewer.core.db.dao.DoujinDetailsDao
+import com.flamyoad.tsukiviewer.core.db.dao.IncludedPathDao
+import com.flamyoad.tsukiviewer.core.model.Doujin
+import com.flamyoad.tsukiviewer.core.model.DoujinDetails
+import com.flamyoad.tsukiviewer.core.utils.ImageFileFilter
+import com.flamyoad.tsukiviewer.core.utils.extensions.toDoujin
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.*
+import java.io.File
+import java.util.*
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DoujinRepository @Inject constructor(
+ private val application: Application,
+ private val doujinDetailsDao: DoujinDetailsDao,
+ private val pathDao: IncludedPathDao
+) {
+ private val contentResolver: ContentResolver = application.contentResolver
+
+ // Cache for the full doujin list - can be set from outside
+ var cachedDoujinList: MutableList? = null
+
+ fun scanForDoujins(keyword: String, tags: String, shouldIncludeAllTags: Boolean)
+ : Flow {
+ val keywordLowerCase = keyword.toLowerCase(Locale.ROOT)
+
+ val flowFromDatabase = searchFromDatabase(keywordLowerCase, tags, shouldIncludeAllTags)
+ val flowFromFileExplorer: Flow = if (tags.isBlank()) {
+ if (cachedDoujinList != null) {
+ searchFromExistingList(keywordLowerCase)
+ } else {
+ searchFromFileExplorer(keywordLowerCase)
+ }
+ } else {
+ emptyFlow()
+ }
+
+ return flowOf(flowFromDatabase, flowFromFileExplorer).flattenMerge()
+ }
+
+ private fun searchFromDatabase(keyword: String, tags: String, shouldIncludeAllTags: Boolean)
+ : Flow = flow {
+
+ if (keyword.isNotBlank() && tags.isNotBlank()) { // Search using both title and tags
+ val tagList = tags.split(",")
+ .map { tagName -> tagName }
+
+ val doujinDetailItems = when (shouldIncludeAllTags) {
+ true -> doujinDetailsDao.findByTags(tagList, tagList.size) // Searched items must include all tags
+ false -> doujinDetailsDao.findByTags(tagList) // Searched items must include at least 1 tag
+ }
+
+ for (item in doujinDetailItems) {
+ val containsKeywordEnglish = item.fullTitleEnglish.toLowerCase(Locale.ROOT).contains(keyword)
+ val containsKeywordJap = item.fullTitleJapanese.contains(keyword)
+
+ if (containsKeywordEnglish || containsKeywordJap) {
+ emit(item.absolutePath.toDoujin() ?: return@flow)
+ }
+ }
+
+ } else if (keyword.isNotBlank() && tags.isBlank()) { // Search using title only
+ val doujinDetailItems = doujinDetailsDao.findByTitle(keyword)
+
+ for (item in doujinDetailItems) {
+ emit(item.absolutePath.toDoujin() ?: return@flow)
+ }
+
+ } else if (tags.isNotBlank() && keyword.isBlank()) { // Search using tags only
+ val tagList = tags.split(",")
+ .map { tagName -> tagName }
+
+ val doujinDetailItems = when (shouldIncludeAllTags) {
+ true -> doujinDetailsDao.findByTags(tagList, tagList.size)
+ false -> doujinDetailsDao.findByTags(tagList)
+ }
+
+ for (item in doujinDetailItems) {
+ emit(item.absolutePath.toDoujin() ?: return@flow)
+ }
+ }
+ }
+
+ private fun searchFromFileExplorer(keyword: String): Flow = flow {
+ val includedDirs = pathDao.getAllBlocking()
+ for (dir in includedDirs) {
+ val pathName = dir.toString()
+
+ val uri = MediaStore.Files.getContentUri("external")
+
+ val projection = arrayOf(
+ MediaStore.Files.FileColumns.DATA,
+ MediaStore.Files.FileColumns.PARENT
+ )
+
+ val selection =
+ "${MediaStore.Files.FileColumns.DATA} LIKE ?" +
+ " AND " +
+ "${MediaStore.Files.FileColumns.TITLE} LIKE ?"
+
+ val params = arrayOf(
+ "%" + pathName + "%",
+ "%" + keyword + "%"
+ )
+
+ val cursor = ContentResolverCompat.query(
+ contentResolver,
+ uri,
+ projection,
+ selection,
+ params,
+ null,
+ null
+ )
+
+ while (cursor.moveToNext()) {
+ val idSet = mutableSetOf()
+
+ val fullPath =
+ cursor.getString(cursor.getColumnIndex(MediaStore.Files.FileColumns.DATA))
+ val parentId =
+ cursor.getString(cursor.getColumnIndex(MediaStore.Files.FileColumns.PARENT))
+
+ if (idSet.add(parentId)) {
+ val doujinDir = File(fullPath)
+
+ val imageList = doujinDir.listFiles(ImageFileFilter())
+
+ if (!imageList.isNullOrEmpty()) {
+ val doujin = Doujin(
+ pic = imageList.first().toUri(),
+ title = doujinDir.name,
+ path = doujinDir,
+ lastModified = doujinDir.lastModified(),
+ numberOfItems = imageList.size
+ )
+ emit(doujin)
+ }
+ }
+ }
+ }
+ }
+
+ private fun searchFromExistingList(keyword: String): Flow = flow {
+ val list = cachedDoujinList ?: return@flow
+
+ val newList = mutableListOf()
+ for (doujin in list) {
+ if (doujin.title.toLowerCase(Locale.ROOT).contains(keyword)) {
+ newList.add(doujin)
+ emit(doujin)
+ }
+ }
+ }
+
+ private fun MutableList.addIfNotNull(doujinDetails: DoujinDetails) {
+ val doujin = doujinDetails.absolutePath.toDoujin()
+ if (doujin != null) {
+ this.add(doujin)
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/MetadataRepository.kt b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/MetadataRepository.kt
new file mode 100644
index 0000000..84e1751
--- /dev/null
+++ b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/MetadataRepository.kt
@@ -0,0 +1,363 @@
+package com.flamyoad.tsukiviewer.core.repository
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.webkit.WebSettings
+import android.widget.Toast
+import androidx.room.withTransaction
+import com.flamyoad.tsukiviewer.core.db.AppDatabase
+import com.flamyoad.tsukiviewer.core.db.dao.*
+import com.flamyoad.tsukiviewer.core.model.DoujinDetails
+import com.flamyoad.tsukiviewer.core.model.DoujinTag
+import com.flamyoad.tsukiviewer.core.model.Source
+import com.flamyoad.tsukiviewer.core.model.Tag
+import com.flamyoad.tsukiviewer.core.network.*
+import com.flamyoad.tsukiviewer.core.network.api.FakkuService
+import com.flamyoad.tsukiviewer.core.network.api.HenNexusService
+import com.flamyoad.tsukiviewer.core.network.api.NHService
+import com.flamyoad.tsukiviewer.core.parser.HenNexusParser
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.Dispatcher
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.io.File
+import java.io.IOException
+import java.util.*
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MetadataRepository @Inject constructor(
+ private val context: Context,
+ private val db: AppDatabase,
+ val pathDao: IncludedPathDao,
+ val doujinDetailsDao: DoujinDetailsDao,
+ val tagDao: TagDao,
+ val doujinTagDao: DoujinTagsDao,
+ val folderDao: IncludedFolderDao
+) {
+ private lateinit var nhService: NHService
+ private lateinit var henNexusService: HenNexusService
+ private lateinit var fakkuService: FakkuService
+
+ private val henNexusParser: HenNexusParser by lazy { HenNexusParser() }
+
+ /*
+ Regex used to remove text between parentheses and brackets
+ Before : (C97) [Batsu Jirushi (Batsu)] AzuLan Shikoshiko Bokou Seikatsu (Azur Lane) [English] [AntaresNL667]
+ After : AzuLan Shikoshiko Bokou Seikatsu
+ */
+ private val regex by lazy {
+ "\\[.*?]|\\(.*?\\)".toRegex()
+ }
+
+ private var toast: Toast? = null
+
+ init {
+ initializeNetwork()
+ }
+
+ private fun initializeNetwork() {
+ // Nhentai API refuses the requests if the user agent is not attached.
+ val dispatcher = Dispatcher().apply {
+ maxRequests = 3
+ }
+
+ val httpClient: OkHttpClient = OkHttpClient.Builder()
+ .addInterceptor(object : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
+ val request = chain.request()
+ .newBuilder()
+ .removeHeader("User-Agent")
+ .addHeader("User-Agent", WebSettings.getDefaultUserAgent(context))
+ .build()
+
+ return chain.proceed(request)
+ }
+ })
+ .addInterceptor(HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ })
+ .dispatcher(dispatcher)
+ .build()
+
+ val nhBuilder = Retrofit.Builder()
+ .client(httpClient)
+ .baseUrl(NHService.baseUrl)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+
+ nhService = nhBuilder.create(NHService::class.java)
+
+ val henNexusBuilder = Retrofit.Builder()
+ .client(httpClient)
+ .baseUrl(HenNexusService.baseUrl)
+ .build()
+
+ henNexusService = henNexusBuilder.create(HenNexusService::class.java)
+
+// val fakkuBuilder = Retrofit.Builder()
+// .client(httpClient)
+// .baseUrl(FakkuService.baseUrl)
+// .build()
+//
+// fakkuService = fakkuBuilder.create(FakkuService::class.java)
+ }
+
+ suspend fun fetchMetadata(dir: File, sources: EnumSet): FetchHistory {
+ return withContext(Dispatchers.IO) {
+ if (doujinDetailsDao.existsByTitle(dir.name) || doujinDetailsDao.existsByAbsolutePath(dir.toString())) {
+ return@withContext FetchHistory(dir, dir.name, FetchStatus.ALREADY_EXISTS)
+
+ } else {
+ val fetchResult = requestMetadata(dir.name, sources)
+ val title = fetchResult.getDoujinTitle() ?: dir.name
+
+ val metadata = fetchResult.metadata
+
+ if (metadata == null) {
+ return@withContext FetchHistory(dir, title, FetchStatus.NO_MATCH)
+ } else {
+ saveMetadata(metadata, dir)
+ return@withContext FetchHistory(dir, title, fetchResult.status)
+ }
+ }
+ }
+ }
+
+ private fun requestMetadata(fullTitle: String, sources: EnumSet): FetchResult {
+ var cleanedTitle: String = ""
+
+ if (sources.contains(Source.NHentai)) {
+ // First try is attempted with title which contains all these , | [] {} symbols
+ val queryNhentaiWithDirName = requestFromNhentai(fullTitle)
+ Log.d("fetchbug", "Query NHentai with Dir Name")
+ if (queryNhentaiWithDirName.status == FetchStatus.SUCCESS) {
+ return queryNhentaiWithDirName
+ }
+
+ cleanedTitle = fullTitle.replace(regex, "")
+ if (cleanedTitle == fullTitle) {
+ return queryNhentaiWithDirName // No point retrying if same string
+ }
+
+ // Retry with cleaned title without symbols
+ Log.d("fetchbug", "Query NHentai with Cleaned Name")
+ val queryNhentaiWithCleanTitle = requestFromNhentai(cleanedTitle)
+ if (queryNhentaiWithCleanTitle.status == FetchStatus.SUCCESS) {
+ return queryNhentaiWithCleanTitle
+ }
+ }
+
+// if (sources.contains(Source.HentaiNexus)) {
+// Log.d("fetchbug", "Query HentaiNexus with Cleaned Name")
+// if (cleanedTitle == "") {
+// cleanedTitle = fullTitle.replace(regex, "")
+// }
+//
+// return requestFromHenNexus(cleanedTitle)
+// }
+
+ Log.d("fetchbug", "No match")
+ return FetchResult(null, FetchStatus.NO_MATCH)
+ }
+
+ private fun requestFromNhentai(fullTitle: String): FetchResult {
+ try {
+ // Wraps query parameter with double quotes to perform exact search
+ val response = nhService.getMetadata("\"" + fullTitle + "\"").execute()
+ val json = response.body() ?: return FetchResult(status = FetchStatus.NETWORK_ERROR)
+
+ return when (json.result.isEmpty()) {
+ true -> FetchResult(status = FetchStatus.NO_MATCH)
+ false -> FetchResult(json, status = FetchStatus.SUCCESS)
+ }
+
+ } catch (e: IOException) {
+ showToast("Failed to fetch metadata")
+ return FetchResult(status = FetchStatus.NETWORK_ERROR)
+ }
+ }
+
+ private fun requestFromHenNexus(fullTitle: String): FetchResult {
+ try {
+ val searchRequest = henNexusService.getSearchResult(fullTitle).execute()
+ val searchResultHtml = searchRequest.body()?.string()
+
+ if (searchResultHtml.isNullOrBlank()) {
+ return FetchResult(status = FetchStatus.NO_MATCH)
+ }
+
+ val firstItemLink = henNexusParser.getFirstItemInList(searchResultHtml)
+
+ val firstItemRequest = henNexusService.getPageUrl(firstItemLink).execute()
+ val firstItemHtml = firstItemRequest.body()?.string()
+
+ if (firstItemHtml.isNullOrBlank()) {
+ return FetchResult(status = FetchStatus.NO_MATCH)
+ }
+
+ val metadata = henNexusParser.parseItem(firstItemHtml)
+
+ if (!metadata.hasValue) {
+ return FetchResult(status = FetchStatus.NO_MATCH)
+ } else {
+ return FetchResult(metadata, status = FetchStatus.SUCCESS)
+ }
+
+ } catch (e: IOException) {
+ showToast("Failed to fetch metadata")
+ return FetchResult(status = FetchStatus.NETWORK_ERROR)
+ }
+ }
+
+ private suspend fun saveMetadata(metadata: Metadata, dir: File): String {
+ // Api might return a list of duplicate results. We only want the first one
+ val item = metadata.result.first()
+
+ val doujinDetails = DoujinDetails(
+ nukeCode = item.nukeCode,
+ fullTitleEnglish = item.title.english,
+ fullTitleJapanese = item.title.japanese ?: "",
+ shortTitleEnglish = item.title.pretty ?: "",
+ absolutePath = dir,
+ folderName = dir.name
+ )
+
+ db.withTransaction {
+ val doujinId = doujinDetailsDao.insert(doujinDetails)
+
+ for (tag in item.tags) {
+ var tagId: Long
+
+ if (tag.name.trim() == "" || tag.name == "null")
+ continue
+
+ if (tagDao.exists(tag.type, tag.name)) {
+ tagDao.incrementCount(tag.type, tag.name)
+ tagId = tagDao.getId(tag.type, tag.name)
+
+ } else {
+ tagId = tagDao.insert(
+ Tag(
+ tagId = null,
+ name = tag.name,
+ type = tag.type,
+ url = tag.url,
+ count = 1
+ )
+ )
+ }
+ doujinTagDao.insert(DoujinTag(doujinId, tagId))
+ }
+ }
+ return item.title.english
+ }
+
+ // Erases previous existing tags and adds tags edited by user
+ suspend fun saveEditedMetadata(doujinDetails: DoujinDetails, tags: List) {
+ withContext(Dispatchers.IO) {
+ db.withTransaction {
+ val absolutePath = doujinDetails.absolutePath.absolutePath
+
+ val doujinId: Long
+
+ // Insert into db if a record identified by its absolute path does not exist yet
+ if (!doujinDetailsDao.existsByAbsolutePath(absolutePath)) {
+ doujinId = doujinDetailsDao.insert(doujinDetails)
+ } else {
+ val fetchedItems: List =
+ doujinDetailsDao.findByAbsolutePath(absolutePath)
+ doujinId = fetchedItems.first().id ?: -1
+ }
+
+ // Decrements book count for all tags in the previous data
+ doujinTagDao.decrementTagCount(doujinId)
+
+ // Removes all rows related to chosen id in the table
+ doujinTagDao.deleteFromDoujin(doujinId)
+
+ // Inserts new tag if has any, increments count for tags that already exist
+ tags.forEach { tag ->
+ insertTagElseIncrement(doujinId, tag)
+ }
+ }
+ }
+ }
+
+ private suspend fun insertTagElseIncrement(doujinId: Long, tag: Tag) {
+ db.withTransaction {
+ val tagId: Long
+
+ if (tag.name.trim() == "" || tag.name == "null")
+ return@withTransaction
+
+ if (tagDao.exists(tag.type, tag.name)) {
+ tagDao.incrementCount(tag.type, tag.name)
+ tagId = tagDao.getId(tag.type, tag.name)
+
+ } else {
+ tagId = tagDao.insert(
+ Tag(
+ tagId = null,
+ name = tag.name,
+ type = tag.type,
+ url = tag.url,
+ count = 1
+ )
+ )
+ }
+ doujinTagDao.insert(
+ DoujinTag(doujinId, tagId)
+ )
+ }
+ }
+
+ suspend fun removeMetadata(doujinDetails: DoujinDetails) {
+ withContext(Dispatchers.IO) {
+ db.withTransaction {
+ doujinDetailsDao.delete(doujinDetails)
+ doujinTagDao.deleteFromDoujin(doujinDetails.id!!)
+ doujinTagDao.decrementTagCount(doujinDetails.id)
+ }
+ }
+ }
+
+ suspend fun resetTags(dir: File, sources: EnumSet) {
+ withContext(Dispatchers.IO) {
+// val result = requestFromNhentai(dir.name)
+ val result = requestMetadata(dir.name, sources)
+
+ if (result.status == FetchStatus.SUCCESS) {
+ val doujinDetails = doujinDetailsDao
+ .findByAbsolutePath(dir.absolutePath)
+ .first()
+
+ if (result.metadata == null) return@withContext
+
+ val tagList = result.metadata.getTags()
+ .map { x -> Tag(type = x.type, name = x.name, url = x.url, count = 1) }
+ saveEditedMetadata(doujinDetails, tagList)
+ } else {
+ Log.d("retrofit", "Can't find this sauce")
+ }
+ }
+ }
+
+ // Need this because we are running in non-UI thread
+ private fun showToast(message: String) {
+ val handler = Handler(Looper.getMainLooper())
+ handler.post {
+ toast?.cancel()
+
+ toast = Toast.makeText(context, message, Toast.LENGTH_SHORT)
+ toast?.show()
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/SearchHistoryRepository.kt b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/SearchHistoryRepository.kt
new file mode 100644
index 0000000..015f5d7
--- /dev/null
+++ b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/SearchHistoryRepository.kt
@@ -0,0 +1,49 @@
+package com.flamyoad.tsukiviewer.core.repository
+
+import androidx.lifecycle.LiveData
+import androidx.paging.PagedList
+import androidx.paging.toLiveData
+import com.flamyoad.tsukiviewer.core.db.dao.SearchHistoryDao
+import com.flamyoad.tsukiviewer.core.model.SearchHistory
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SearchHistoryRepository @Inject constructor(
+ val searchHistoryDao: SearchHistoryDao
+) {
+
+ fun getAll(pageSize: Int): LiveData> {
+ return searchHistoryDao.getAll()
+ .toLiveData(pageSize)
+ }
+
+ suspend fun insertSearchHistory(item: SearchHistory) {
+ withContext(Dispatchers.IO) {
+ val lastInsertedItem = searchHistoryDao.getLatestItem()
+
+ /* 1st condition: If last inserted item is null, means the search history has 0 items
+ 2nd condition: If user inputs the same thing as previous search,
+ then there is no need to insert into database
+ */
+ val shouldInsert = lastInsertedItem == null || !lastInsertedItem.sameWith(item)
+ if (shouldInsert) {
+ searchHistoryDao.insert(item)
+ }
+ }
+ }
+
+ suspend fun deleteSingle(item: SearchHistory) {
+ withContext(Dispatchers.IO) {
+ searchHistoryDao.delete(item)
+ }
+ }
+
+ suspend fun deleteAll() {
+ withContext(Dispatchers.IO) {
+ searchHistoryDao.deleteAll()
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/TagRepository.kt b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/TagRepository.kt
new file mode 100644
index 0000000..7bb118c
--- /dev/null
+++ b/core/src/main/java/com/flamyoad/tsukiviewer/core/repository/TagRepository.kt
@@ -0,0 +1,21 @@
+package com.flamyoad.tsukiviewer.core.repository
+
+import androidx.lifecycle.LiveData
+import com.flamyoad.tsukiviewer.core.db.dao.TagDao
+import com.flamyoad.tsukiviewer.core.model.Tag
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class TagRepository @Inject constructor(
+ private val tagDao: TagDao
+) {
+
+ fun getAll(): LiveData> {
+ return tagDao.getAll()
+ }
+
+ fun getAllWithFilter(keyword: String): LiveData> {
+ return tagDao.getAllWithFilter(keyword)
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/flamyoad/tsukiviewer/core/utils/ImageFileFilter.kt b/core/src/main/java/com/flamyoad/tsukiviewer/core/utils/ImageFileFilter.kt
new file mode 100644
index 0000000..4193e67
--- /dev/null
+++ b/core/src/main/java/com/flamyoad/tsukiviewer/core/utils/ImageFileFilter.kt
@@ -0,0 +1,23 @@
+package com.flamyoad.tsukiviewer.core.utils
+
+import java.io.File
+import java.io.FileFilter
+import java.util.*
+
+val imageExtensions = arrayOf("jpg", "png", "gif", "jpeg", "webp", "jpe", "bmp")
+
+class ImageFileFilter : FileFilter {
+
+ override fun accept(file: File?): Boolean {
+ if (file == null) {
+ return false
+ }
+
+ for (extension in imageExtensions) {
+ if (file.name.lowercase(Locale.ROOT).endsWith(extension)) {
+ return true
+ }
+ }
+ return false
+ }
+}
diff --git a/core/src/main/java/com/flamyoad/tsukiviewer/core/utils/extensions/FileExtensions.kt b/core/src/main/java/com/flamyoad/tsukiviewer/core/utils/extensions/FileExtensions.kt
new file mode 100644
index 0000000..6568be4
--- /dev/null
+++ b/core/src/main/java/com/flamyoad/tsukiviewer/core/utils/extensions/FileExtensions.kt
@@ -0,0 +1,20 @@
+package com.flamyoad.tsukiviewer.core.utils.extensions
+
+import androidx.core.net.toUri
+import com.flamyoad.tsukiviewer.core.model.Doujin
+import com.flamyoad.tsukiviewer.core.utils.ImageFileFilter
+import java.io.File
+
+fun File.toDoujin(): Doujin? {
+ val imageList = this.listFiles(ImageFileFilter()) ?: return null
+ if (imageList.isEmpty()) return null
+
+ val doujin = Doujin(
+ pic = imageList.first().toUri(),
+ title = this.name,
+ path = this,
+ lastModified = this.lastModified(),
+ numberOfItems = imageList.size
+ )
+ return doujin
+}
|