mirror of
https://github.com/mihonapp/mihon.git
synced 2025-03-01 18:34:13 +01:00
Add option to export minimal library information to a CSV file (#1161)
This commit is contained in:
parent
8b28a9bcee
commit
fab8b17d99
@ -18,6 +18,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
||||
- Add Monochrome theme (made with e-ink displays in mind) ([@MajorTanya](https://github.com/MajorTanya)) ([#1752](https://github.com/mihonapp/mihon/pull/1752))
|
||||
- Support for private tracking with AniList and Bangumi ([@NarwhalHorns](https://github.com/NarwhalHorns)) ([#1736](https://github.com/mihonapp/mihon/pull/1736))
|
||||
- Add private tracking support for Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#1774](https://github.com/mihonapp/mihon/pull/1774))
|
||||
- Add option to export minimal library information to a CSV file ([@Animeboynz](https://github.com/Animeboynz), [@AntsyLich](https://github.com/AntsyLich)) ([#1161](https://github.com/mihonapp/mihon/pull/1161))
|
||||
|
||||
### Changed
|
||||
- Apply "Downloaded only" filter to all entries regardless of favourite status ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#1603](https://github.com/mihonapp/mihon/pull/1603))
|
||||
|
@ -7,7 +7,9 @@ import android.net.Uri
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@ -15,6 +17,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MultiChoiceSegmentedButtonRow
|
||||
@ -22,12 +26,15 @@ import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
@ -45,10 +52,14 @@ import eu.kanade.presentation.util.relativeTimeSpanString
|
||||
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
|
||||
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.export.LibraryExporter
|
||||
import eu.kanade.tachiyomi.data.export.LibraryExporter.ExportOptions
|
||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.storage.displayablePath
|
||||
@ -57,8 +68,11 @@ import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.backup.service.BackupPreferences
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.manga.interactor.GetFavorites
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.storage.service.StoragePreferences
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.TextButton
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import uy.kohesive.injekt.Injekt
|
||||
@ -95,6 +109,7 @@ object SettingsDataScreen : SearchableSettings {
|
||||
|
||||
getBackupAndRestoreGroup(backupPreferences = backupPreferences),
|
||||
getDataGroup(),
|
||||
getExportGroup(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -312,4 +327,141 @@ object SettingsDataScreen : SearchableSettings {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getExportGroup(): Preference.PreferenceGroup {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var exportOptions by remember {
|
||||
mutableStateOf(
|
||||
ExportOptions(
|
||||
includeTitle = true,
|
||||
includeAuthor = true,
|
||||
includeArtist = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val getFavorites = remember { Injekt.get<GetFavorites>() }
|
||||
var favorites by remember { mutableStateOf<List<Manga>>(emptyList()) }
|
||||
LaunchedEffect(Unit) {
|
||||
favorites = getFavorites.await()
|
||||
}
|
||||
|
||||
val saveFileLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/csv"),
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
scope.launch {
|
||||
LibraryExporter.exportToCsv(
|
||||
context = context,
|
||||
uri = it,
|
||||
favorites = favorites,
|
||||
options = exportOptions,
|
||||
onExportComplete = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
context.toast(MR.strings.library_exported)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ColumnSelectionDialog(
|
||||
options = exportOptions,
|
||||
onConfirm = { options ->
|
||||
exportOptions = options
|
||||
saveFileLauncher.launch("mihon_library.csv")
|
||||
},
|
||||
onDismissRequest = { showDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
return Preference.PreferenceGroup(
|
||||
title = stringResource(MR.strings.export),
|
||||
preferenceItems = persistentListOf(
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.library_list),
|
||||
onClick = { showDialog = true },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnSelectionDialog(
|
||||
options: ExportOptions,
|
||||
onConfirm: (ExportOptions) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
var titleSelected by remember { mutableStateOf(options.includeTitle) }
|
||||
var authorSelected by remember { mutableStateOf(options.includeAuthor) }
|
||||
var artistSelected by remember { mutableStateOf(options.includeArtist) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = {
|
||||
Text(text = stringResource(MR.strings.migration_dialog_what_to_include))
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = titleSelected,
|
||||
onCheckedChange = { checked ->
|
||||
titleSelected = checked
|
||||
if (!checked) {
|
||||
authorSelected = false
|
||||
artistSelected = false
|
||||
}
|
||||
},
|
||||
)
|
||||
Text(text = stringResource(MR.strings.title))
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = authorSelected,
|
||||
onCheckedChange = { authorSelected = it },
|
||||
enabled = titleSelected,
|
||||
)
|
||||
Text(text = stringResource(MR.strings.author))
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = artistSelected,
|
||||
onCheckedChange = { artistSelected = it },
|
||||
enabled = titleSelected,
|
||||
)
|
||||
Text(text = stringResource(MR.strings.artist))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onConfirm(
|
||||
ExportOptions(
|
||||
includeTitle = titleSelected,
|
||||
includeAuthor = authorSelected,
|
||||
includeArtist = artistSelected,
|
||||
),
|
||||
)
|
||||
onDismissRequest()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.action_save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,64 @@
|
||||
package eu.kanade.tachiyomi.data.export
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
|
||||
object LibraryExporter {
|
||||
|
||||
data class ExportOptions(
|
||||
val includeTitle: Boolean,
|
||||
val includeAuthor: Boolean,
|
||||
val includeArtist: Boolean,
|
||||
)
|
||||
|
||||
suspend fun exportToCsv(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
favorites: List<Manga>,
|
||||
options: ExportOptions,
|
||||
onExportComplete: () -> Unit,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
val csvData = generateCsvData(favorites, options)
|
||||
outputStream.write(csvData.toByteArray())
|
||||
}
|
||||
onExportComplete()
|
||||
}
|
||||
}
|
||||
|
||||
private val escapeRequired = listOf("\r", "\n", "\"", ",")
|
||||
|
||||
private fun generateCsvData(favorites: List<Manga>, options: ExportOptions): String {
|
||||
val columnSize = listOf(
|
||||
options.includeTitle,
|
||||
options.includeAuthor,
|
||||
options.includeArtist,
|
||||
)
|
||||
.count { it }
|
||||
|
||||
val rows = buildList(favorites.size) {
|
||||
favorites.forEach { manga ->
|
||||
buildList(columnSize) {
|
||||
if (options.includeTitle) add(manga.title)
|
||||
if (options.includeAuthor) add(manga.author)
|
||||
if (options.includeArtist) add(manga.artist)
|
||||
}
|
||||
.let(::add)
|
||||
}
|
||||
}
|
||||
return rows.joinToString("\r\n") { columns ->
|
||||
columns.joinToString(",") columns@{ column ->
|
||||
if (column.isNullOrBlank()) return@columns ""
|
||||
if (escapeRequired.any { column.contains(it) }) {
|
||||
column.replace("\"", "\"\"").let { "\"$it\"" }
|
||||
} else {
|
||||
column
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -573,6 +573,9 @@
|
||||
<string name="cache_deleted">Cache cleared, %1$d files deleted</string>
|
||||
<string name="cache_delete_error">Error occurred while clearing</string>
|
||||
<string name="pref_auto_clear_chapter_cache">Clear chapter cache on app launch</string>
|
||||
<string name="export">Export</string>
|
||||
<string name="library_list">Library List</string>
|
||||
<string name="library_exported">Library Exported</string>
|
||||
|
||||
<!-- Sync section -->
|
||||
<string name="syncing_library">Syncing library</string>
|
||||
@ -688,6 +691,8 @@
|
||||
<string name="ongoing">Ongoing</string>
|
||||
<string name="unknown">Unknown</string>
|
||||
<string name="unknown_author">Unknown author</string>
|
||||
<string name="author">Author</string>
|
||||
<string name="artist">Artist</string>
|
||||
<!-- reserved for #6163 -->
|
||||
<string name="unknown_status">Unknown status</string>
|
||||
<string name="licensed">Licensed</string>
|
||||
|
Loading…
x
Reference in New Issue
Block a user