Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added import/export to html functionality #54

Merged
merged 6 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,7 @@ dependencies {
androidTestImplementation "androidx.arch.core:core-testing:$rootProject.archCoreTestVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$rootProject.coroutinesTestVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$rootProject.espressoVersion"

// Html parsing lib
implementation "org.jsoup:jsoup:$rootProject.jsoupVersion"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.amrdeveloper.linkhub.data

enum class ImportExportFileType(val mimeType: String, val extension: String) {

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove empty line

JSON( "application/json",".json"),
HTML("text/html", ".html")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.amrdeveloper.linkhub.data.parser

import com.amrdeveloper.linkhub.data.DataPackage
import com.amrdeveloper.linkhub.data.Folder
import com.amrdeveloper.linkhub.data.FolderColor
import com.amrdeveloper.linkhub.data.ImportExportFileType
import com.amrdeveloper.linkhub.data.Link
import com.amrdeveloper.linkhub.data.source.FolderRepository
import com.amrdeveloper.linkhub.data.source.LinkRepository
import com.amrdeveloper.linkhub.util.UiPreferences
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.select.Selector

class HtmlImportExportFileParser: ImportExportFileParser {
override fun getFileType(): ImportExportFileType = ImportExportFileType.HTML

override suspend fun importData(data: String, folderRepository: FolderRepository, linkRepository: LinkRepository): Result<DataPackage?> {
try {
val doc: Document = Jsoup.parse(data)
val folders = doc.select("h3")
for (i in folders.indices) {
val folder = Folder(folders[i].text()).apply {
folderColor = FolderColor.BLUE
}
//the default case - no folder id
var folderId = -1
val getFolderRes = folderRepository.getFolderByName(folder.name)
//a case when a folder does already exists
if (getFolderRes.isSuccess && getFolderRes.getOrNull() != null) {
val existingFolder = getFolderRes.getOrNull()!!
folderId = existingFolder.id
} else {
//a case when a folder does not exists
val addFolderRes = folderRepository.insertFolder(folder)
if (addFolderRes.isSuccess) {
folderId = addFolderRes.getOrDefault(-1).toInt()
}
}

val folderLinks = mutableListOf<Link>()
val nextDL = folders[i].nextElementSibling()
val links = nextDL.select("a")
for (j in links.indices) {
val link = links[j]
val title = link.text()
val url = link.attr("href")
//subtitle = title = link name
folderLinks.add(Link(title, title, url, folderId = folderId))
}
linkRepository.insertLinks(folderLinks)
}
// If there are bookmarks without a folder, add then individually
val rootDL = doc.select("dl").firstOrNull()
val folderLinks = mutableListOf<Link>()
if (rootDL != null) {
val individualBookmarks = rootDL.select("> dt > a")
if (individualBookmarks.isNotEmpty()) {
for (bookmarkElement in individualBookmarks) {
val title = bookmarkElement.text()
val url = bookmarkElement.attr("href")
folderLinks.add(Link(title, title, url))
}
}
}
linkRepository.insertLinks(folderLinks)
return Result.success(null)
} catch (e: Selector.SelectorParseException){
return Result.failure(e)
}
}
override suspend fun exportData(
folderRepository: FolderRepository,
linkRepository: LinkRepository,
uiPreferences: UiPreferences
): Result<String> {
val foldersResult = folderRepository.getFolderList()
if (foldersResult.isSuccess) {
val folders = foldersResult.getOrDefault(listOf())
val htmlString = buildString {
appendLine("<!DOCTYPE NETSCAPE-Bookmark-file-1>\n")
appendLine("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n")
appendLine("<TITLE>Bookmarks</TITLE>\n")
appendLine("<H1>Bookmarks</H1>\n")
appendLine("<DL><p>\n")

folders.forEach {
val linksGetResult = linkRepository.getSortedFolderLinkList(it.id)
appendLine("<DT><H3>${it.name}</H3>\n")
if (linksGetResult.isSuccess) {
val links = linksGetResult.getOrDefault(listOf())
appendLine("<DL><p>\n")
links.forEach { link ->
appendLine("<DT><A HREF=\"${link.url}\">${link.title}</A>\n")
}
appendLine("</DL><p>\n")
}

}

val bookmarks: List<Link> = linkRepository.getSortedFolderLinkList(-1).getOrDefault(
listOf()
)
if (bookmarks.isNotEmpty()) {
bookmarks.forEach { link ->
appendLine("<DT><A HREF=\"${link.url}\">${link.title}</A>\n")
}
}
appendLine("</DL><p>\n")

}
return Result.success(htmlString)
}
return Result.failure(Throwable())
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.amrdeveloper.linkhub.data.parser

import com.amrdeveloper.linkhub.data.DataPackage
import com.amrdeveloper.linkhub.data.ImportExportFileType
import com.amrdeveloper.linkhub.data.source.FolderRepository
import com.amrdeveloper.linkhub.data.source.LinkRepository
import com.amrdeveloper.linkhub.util.UiPreferences

interface ImportExportFileParser {
suspend fun importData(data: String, folderRepository: FolderRepository, linkRepository: LinkRepository): Result<DataPackage?>
suspend fun exportData(
folderRepository: FolderRepository,
linkRepository: LinkRepository,
uiPreferences: UiPreferences
): Result<String>
fun getFileType(): ImportExportFileType
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.amrdeveloper.linkhub.data.parser

import com.amrdeveloper.linkhub.data.DataPackage
import com.amrdeveloper.linkhub.data.FolderColor
import com.amrdeveloper.linkhub.data.ImportExportFileType
import com.amrdeveloper.linkhub.data.source.FolderRepository
import com.amrdeveloper.linkhub.data.source.LinkRepository
import com.amrdeveloper.linkhub.util.UiPreferences
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException

class JsonImportExportFileParser: ImportExportFileParser {
override fun getFileType(): ImportExportFileType = ImportExportFileType.JSON
override suspend fun importData(
data: String,
folderRepository: FolderRepository,
linkRepository: LinkRepository
): Result<DataPackage?> {
try{
val dataPackage = Gson().fromJson(data, DataPackage::class.java)

val folders = dataPackage.folders
// This code should be removed after found why it not serialized on some devices (see Issue #23)
// folderColor field is declared as non nullable type but in this case GSON will break the null safty feature
folders.forEach { if (it.folderColor == null) it.folderColor = FolderColor.BLUE }
folderRepository.insertFolders(folders)

linkRepository.insertLinks(dataPackage.links)
return Result.success(dataPackage)
} catch (e : JsonSyntaxException) {
return Result.failure(e)
}
}
override suspend fun exportData(
folderRepository: FolderRepository,
linkRepository: LinkRepository,
uiPreferences: UiPreferences
): Result<String>{
val foldersResult = folderRepository.getFolderList()
val linksResult = linkRepository.getLinkList()
if (foldersResult.isSuccess && linksResult.isSuccess) {
val folders = foldersResult.getOrDefault(listOf())
val links = linksResult.getOrDefault(listOf())
val showClickCounter = uiPreferences.isClickCounterEnabled()
val autoSaving = uiPreferences.isAutoSavingEnabled()
val lastTheme = uiPreferences.getThemeType()
val dataPackage = DataPackage(folders, links, showClickCounter, autoSaving, lastTheme)
return Result.success(Gson().toJson(dataPackage))
} else {
return Result.failure(Throwable());
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface FolderDataSource {

suspend fun getFolderById(id: Int): Result<Folder>

suspend fun getFolderByName(name: String): Result<Folder>

suspend fun getFolderList(): Result<List<Folder>>

suspend fun getSortedFolderList(): Result<List<Folder>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class FolderRepository(private val dataSource: FolderDataSource) {
suspend fun getFolderById(folderId : Int) : Result<Folder> {
return dataSource.getFolderById(folderId)
}
suspend fun getFolderByName(name : String) : Result<Folder> {
return dataSource.getFolderByName(name)
}

suspend fun getFolderList(): Result<List<Folder>> {
return dataSource.getFolderList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ interface FolderDao : BaseDao<Folder> {
@Query("SELECT * FROM folder WHERE id = :id LIMIT 1")
suspend fun getFolderById(id : Int) : Folder

@Query("SELECT * FROM folder WHERE name = :name LIMIT 1")
suspend fun getFolderByName(name : String) : Folder

@Query("SELECT * FROM folder")
suspend fun getFolderList(): List<Folder>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ class FolderLocalDataSource internal constructor(
Result.failure(e)
}
}
override suspend fun getFolderByName(name : String): Result<Folder> = withContext(ioDispatcher) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add new line before method declaration

return@withContext try {
Result.success(folderDao.getFolderByName(name))
} catch (e: Exception) {
Result.failure(e)
}
}

override suspend fun getFolderList(): Result<List<Folder>> = withContext(ioDispatcher) {
return@withContext try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.amrdeveloper.linkhub.R
import com.amrdeveloper.linkhub.data.ImportExportFileType
import com.amrdeveloper.linkhub.data.parser.HtmlImportExportFileParser
import com.amrdeveloper.linkhub.data.parser.ImportExportFileParser
import com.amrdeveloper.linkhub.data.parser.JsonImportExportFileParser
import com.amrdeveloper.linkhub.databinding.FragmentImportExportBinding
import com.amrdeveloper.linkhub.util.getFileName
import com.amrdeveloper.linkhub.util.getFileText
Expand All @@ -25,6 +29,7 @@ class ImportExportFragment : Fragment() {

private var _binding: FragmentImportExportBinding? = null
private val binding get() = _binding!!
private lateinit var importExportFileParser: ImportExportFileParser

private val importExportViewModel by viewModels<ImportExportViewModel>()

Expand All @@ -36,6 +41,13 @@ class ImportExportFragment : Fragment() {

setupListeners()
setupObservers()
//Initialization in real time, because the file type is chosen by a user
val args = ImportExportFragmentArgs.fromBundle(requireArguments())
val importExportFileType = args.importExportFileType
importExportFileParser = when(importExportFileType){
ImportExportFileType.JSON -> JsonImportExportFileParser()
ImportExportFileType.HTML -> HtmlImportExportFileParser()
}

return binding.root
}
Expand Down Expand Up @@ -78,16 +90,17 @@ class ImportExportFragment : Fragment() {
private fun exportFileFromDeviceWthPermission() {
if (Build.VERSION_CODES.R > Build.VERSION.SDK_INT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val readPermissionState = checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE)
if (readPermissionState == PackageManager.PERMISSION_GRANTED) importExportViewModel.exportDataFile(requireContext())
if (readPermissionState == PackageManager.PERMISSION_GRANTED)
importExportViewModel.exportDataFile(requireContext(), importExportFileParser)
else permissionLauncher.launch(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE))
} else {
importExportViewModel.exportDataFile(requireContext())
importExportViewModel.exportDataFile(requireContext(), importExportFileParser)
}
}

private fun launchFileChooserIntent() {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "application/json"
intent.type = importExportFileParser.getFileType().mimeType
intent.addCategory(Intent.CATEGORY_OPENABLE)
val chooserIntent = Intent.createChooser(intent, "Select a File to import")
loadFileActivityResult.launch(chooserIntent)
Expand All @@ -96,7 +109,7 @@ class ImportExportFragment : Fragment() {
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
if(result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true) {
importExportViewModel.exportDataFile(requireContext())
importExportViewModel.exportDataFile(requireContext(), importExportFileParser)
}
else if(result[Manifest.permission.READ_EXTERNAL_STORAGE] == true) {
launchFileChooserIntent()
Expand All @@ -113,12 +126,12 @@ class ImportExportFragment : Fragment() {
val contentResolver = requireActivity().contentResolver
val fileName = contentResolver.getFileName(fileUri)
val extension = fileName.substring(fileName.lastIndexOf('.') + 1)
if (extension != "json") {
if ((".$extension") != importExportFileParser.getFileType().extension) {
activity?.showSnackBar(R.string.message_invalid_extension)
return@registerForActivityResult
}
val fileContent = contentResolver.getFileText(fileUri)
importExportViewModel.importDataFile(fileContent)
importExportViewModel.importDataFile(fileContent, importExportFileParser)
}
}
}
Expand Down
Loading