diff --git a/CHANGELOG.md b/CHANGELOG.md index 883d1725d..a5f8f3477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index 14866e284..9b65ed4c1 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -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() } + var favorites by remember { mutableStateOf>(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)) + } + }, + ) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/export/LibraryExporter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/export/LibraryExporter.kt new file mode 100644 index 000000000..824110483 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/export/LibraryExporter.kt @@ -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, + 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, 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 + } + } + } + } +} diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 27d321f01..2ca4e9249 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -573,6 +573,9 @@ Cache cleared, %1$d files deleted Error occurred while clearing Clear chapter cache on app launch + Export + Library List + Library Exported Syncing library @@ -688,6 +691,8 @@ Ongoing Unknown Unknown author + Author + Artist Unknown status Licensed