From 333b200a995f49d5959d68169105f00e66117ae8 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sun, 17 Nov 2024 20:03:09 +0100 Subject: [PATCH] [Feature] Export list as csv #1622 --- .../jtx/database/relations/ICal4ListRel.kt | 105 ++++++++++++++++++ .../jtx/ui/list/ListScreenTabContainer.kt | 61 ++++++++++ .../java/at/techbee/jtx/util/DateTimeUtils.kt | 13 +++ app/src/main/res/values/strings.xml | 3 + 4 files changed, 182 insertions(+) diff --git a/app/src/main/java/at/techbee/jtx/database/relations/ICal4ListRel.kt b/app/src/main/java/at/techbee/jtx/database/relations/ICal4ListRel.kt index b749b11e5..7220d7675 100644 --- a/app/src/main/java/at/techbee/jtx/database/relations/ICal4ListRel.kt +++ b/app/src/main/java/at/techbee/jtx/database/relations/ICal4ListRel.kt @@ -15,6 +15,7 @@ import at.techbee.jtx.R import at.techbee.jtx.database.COLUMN_ID import at.techbee.jtx.database.Classification import at.techbee.jtx.database.ICalObject +import at.techbee.jtx.database.ICalObject.Companion.TZ_ALLDAY import at.techbee.jtx.database.Module import at.techbee.jtx.database.Status import at.techbee.jtx.database.properties.COLUMN_CATEGORY_ICALOBJECT_ID @@ -218,5 +219,109 @@ data class ICal4ListRel( null -> sortedList.groupBy { it.iCal4List.module } } } + + /** + * Generates a string of headers for a CSV + * Use getCSVRow(...) to get the row that correponds to this header + * @return a String with the headers delimited by | + */ + fun getCSVHeader(module: Module, context: Context): String { + return mutableListOf().apply { + if (module == Module.JOURNAL) + add(context.getString(R.string.date)) + if (module == Module.TODO) { + add(context.getString(R.string.started)) + add(context.getString(R.string.due)) + add(context.getString(R.string.completed)) + } + add(context.getString(R.string.summary)) + add(context.getString(R.string.description)) + + if (module == Module.TODO) + add(context.getString(R.string.progress)) + + add(context.getString(R.string.categories)) + if (module == Module.TODO) + add(context.getString(R.string.resources)) + + add(context.getString(R.string.status)) + add(context.getString(R.string.classification)) + if (module == Module.TODO) + add(context.getString(R.string.priority)) + + add(context.getString(R.string.contact)) + add(context.getString(R.string.url)) + add(context.getString(R.string.location)) + add(context.getString(R.string.latitude) + "/" + context.getString(R.string.longitude)) + + add(context.getString(R.string.subtasks)) + add(context.getString(R.string.view_feedback_linked_notes)) + add(context.getString(R.string.attachments)) + add(context.getString(R.string.attendees)) + add(context.getString(R.string.comments)) + add(context.getString(R.string.alarms)) + + add(context.getString(R.string.account)) + add(context.getString(R.string.collection)) + + add(context.getString(R.string.filter_created)) + add(context.getString(R.string.filter_last_modified)) + }.joinToString(separator = "|") + } + } + + + /** + * @return string of values o the current entry delimited by | that corresponds to the headers in getCSVHeader(...) + */ + fun getCSVRow(context: Context): String { + return mutableListOf().apply { + if(iCal4List.module == Module.JOURNAL.name) + add(DateTimeUtils.convertLongToExcelDateTimeString(iCal4List.dtstart, if(iCal4List.dtstartTimezone == TZ_ALLDAY) "UTC" else iCal4List.dtstartTimezone)) + if(iCal4List.module == Module.TODO.name) { + add(DateTimeUtils.convertLongToExcelDateTimeString(iCal4List.dtstart, if(iCal4List.dtstartTimezone == TZ_ALLDAY) "UTC" else iCal4List.dtstartTimezone)) + add(DateTimeUtils.convertLongToExcelDateTimeString(iCal4List.due, if(iCal4List.dueTimezone == TZ_ALLDAY) "UTC" else iCal4List.dueTimezone)) + add(DateTimeUtils.convertLongToExcelDateTimeString(iCal4List.completed, if(iCal4List.completedTimezone == TZ_ALLDAY) "UTC" else iCal4List.completedTimezone)) + } + + add(iCal4List.summary?:"") + add(iCal4List.description?:"") + + if(iCal4List.module == Module.TODO.name) + add(iCal4List.percent?.toString()?:"") + + add(iCal4List.getSplitCategories().joinToString(separator = ", ")) + if(iCal4List.module == Module.TODO.name) + add(iCal4List.resources?:"") + + add(Status.getStatusFromString(iCal4List.status)?.let { + context.getString(it.stringResource) + }?:iCal4List.status ?:"") + add(Classification.getClassificationFromString(iCal4List.classification)?.let { + context.getString(it.stringResource) + }?:iCal4List.classification ?:"") + if(iCal4List.module == Module.TODO.name) + add(if (iCal4List.priority in 1..9) context.resources.getStringArray(R.array.priority)[iCal4List.priority!!] else "") + + add(iCal4List.contact?:"") + add(iCal4List.url?:"") + add(iCal4List.location?:"") + add(if(iCal4List.geoLat != null && iCal4List.geoLong != null) + "(" + iCal4List.geoLat!!.toString() + ", " + iCal4List.geoLong!!.toString() + ")" else "" + ) + + add(iCal4List.numSubtasks.toString()) + add(iCal4List.numSubnotes.toString()) + add(iCal4List.numAttachments.toString()) + add(iCal4List.numAttendees.toString()) + add(iCal4List.numComments.toString()) + add(iCal4List.numAlarms.toString()) + + add(iCal4List.accountName?:"") + add(iCal4List.collectionDisplayName?:"") + + add(DateTimeUtils.convertLongToExcelDateTimeString(iCal4List.created, null)) + add(DateTimeUtils.convertLongToExcelDateTimeString(iCal4List.lastModified, null)) + }.joinToString(separator = "|") } } \ No newline at end of file diff --git a/app/src/main/java/at/techbee/jtx/ui/list/ListScreenTabContainer.kt b/app/src/main/java/at/techbee/jtx/ui/list/ListScreenTabContainer.kt index e0b561702..1a147b24c 100644 --- a/app/src/main/java/at/techbee/jtx/ui/list/ListScreenTabContainer.kt +++ b/app/src/main/java/at/techbee/jtx/ui/list/ListScreenTabContainer.kt @@ -15,16 +15,20 @@ import android.content.pm.PackageManager import android.location.LocationListener import android.location.LocationManager import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.biometric.BiometricPrompt import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Search @@ -65,6 +69,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -80,6 +86,7 @@ import at.techbee.jtx.database.properties.Alarm import at.techbee.jtx.database.properties.AlarmRelativeTo import at.techbee.jtx.database.properties.Attachment import at.techbee.jtx.database.properties.Category +import at.techbee.jtx.database.relations.ICal4ListRel import at.techbee.jtx.flavored.BillingManager import at.techbee.jtx.ui.GlobalStateHolder import at.techbee.jtx.ui.reusable.appbars.JtxNavigationDrawer @@ -92,12 +99,15 @@ import at.techbee.jtx.ui.reusable.elements.CheckboxWithText import at.techbee.jtx.ui.reusable.elements.RadiobuttonWithText import at.techbee.jtx.ui.settings.DropdownSettingOption import at.techbee.jtx.ui.settings.SettingsStateHolder +import at.techbee.jtx.util.DateTimeUtils import at.techbee.jtx.util.SyncUtil import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.io.IOException import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -185,6 +195,28 @@ fun ListScreenTabContainer( } } + val launcherExportToCSV = rememberLauncherForActivityResult(CreateDocument("text/csv")) { + it?.let { uri -> + //isProcessing.postValue(true) + scope.launch(Dispatchers.IO) { + try { + context.contentResolver?.openOutputStream(uri)?.use { outputStream -> + val csvData = mutableListOf() + csvData.add(ICal4ListRel.getCSVHeader(listViewModel.module, context)) + listViewModel.iCal4ListRel.value?.forEach { ical4list -> + csvData.add(ical4list.getCSVRow(context)) + } + //Log.d("CSV", csvData.joinToString(separator = System.lineSeparator())) + outputStream.write(csvData.joinToString(separator = System.lineSeparator()).toByteArray()) + } + listViewModel.toastMessage.value = context.getString(R.string.list_toast_export_success) + } catch (e: IOException) { + listViewModel.toastMessage.value = context.getString(R.string.list_toast_export_error) + } + } + } + } + var topBarMenuExpanded by remember { mutableStateOf(false) } var showDeleteSelectedDialog by rememberSaveable { mutableStateOf(false) } var showUpdateEntriesDialog by rememberSaveable { mutableStateOf(false) } @@ -578,6 +610,35 @@ fun ListScreenTabContainer( getActiveViewModel().updateSearch(saveListSettings = true, isAuthenticated = globalStateHolder.isAuthenticated.value) } ) + HorizontalDivider() + DropdownMenuItem( + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(id = R.string.export_as_csv), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(end = 16.dp) + ) + Text( + text = stringResource(id = R.string.settings_attention_experimental_feature), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.widthIn(max = 150.dp), + fontStyle = FontStyle.Italic + ) + } + }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = null + ) + }, + onClick = { + launcherExportToCSV.launch("jtx_csvExport_${DateTimeUtils.timestampAsFilenameAppendix()}.csv") + topBarMenuExpanded = false + } + ) } } ) diff --git a/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt b/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt index 17e9863d6..222d79599 100644 --- a/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt +++ b/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt @@ -63,6 +63,19 @@ object DateTimeUtils { return zonedDateTime.format(formatter) } + /** + * Creates a string from the date that can be used for the CSV export + */ + fun convertLongToExcelDateTimeString(date: Long?, timezone: String?): String { + if (date == null || date == 0L) + return "" + val zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(date), requireTzId(timezone)) + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.SHORT) + return zonedDateTime.format(formatter) + } + + + fun convertLongToFullDateString(date: Long?, timezone: String?): String { if (date == null || date == 0L) return "" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0df35be42..47e73cfec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -265,6 +265,7 @@ "Add remote Collection (%1$s)" "Show in %1$s" "Export as .ics" + "Export as .csv" "Journal" "Note" "Task" @@ -346,6 +347,8 @@ "ends never" "ends on" "ends after" + "List successfully exported" + "Error on exporting list" "Collections successfully exported" "Error on exporting collections" "Export all"