From 2ec5c00df17bc324eb9c02360ffe4eff17a1d6fc Mon Sep 17 00:00:00 2001 From: soraefir Date: Wed, 9 Sep 2026 08:53:41 +0200 Subject: [PATCH] Import/Export Improvements --- .../android/project/ProjectImportHelper.kt | 76 ++++++++++++++++--- .../net/helcel/cowspent/util/ExportUtil.kt | 36 +++++---- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt index f086849..461c13b 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt @@ -11,11 +11,30 @@ import net.helcel.cowspent.R import net.helcel.cowspent.model.* import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import java.io.InputStreamReader +import java.net.URLDecoder import java.text.SimpleDateFormat import java.util.* object ProjectImportHelper { + private fun optional(line: Array, columns: Map, name: String): String = + columns[name]?.takeIf { it < line.size }?.let { line[it] } ?: "" + + /** Parses the `#rrggbb` member colour Cospend writes in its members section. */ + private fun parseHexColor(value: String): Triple? { + val hex = value.trim().removePrefix("#") + if (hex.length != 6) return null + return try { + Triple( + hex.substring(0, 2).toInt(16), + hex.substring(2, 4).toInt(16), + hex.substring(4, 6).toInt(16) + ) + } catch (_: NumberFormatException) { + null + } + } + @SuppressLint("Range") fun getFileName(contentResolver: ContentResolver, uri: Uri): String { var result: String? = null @@ -59,6 +78,7 @@ object ProjectImportHelper { val bills = mutableListOf() val membersActive = mutableMapOf() val membersWeight = mutableMapOf() + val membersColor = mutableMapOf>() val billRemoteIdToPayerName = mutableMapOf() val billRemoteIdToOwerStr = mutableMapOf() @@ -76,7 +96,10 @@ object ProjectImportHelper { currentSection = when { columns.containsKey("what") && columns.containsKey("amount") -> "bills" + columns.containsKey("name") && columns.containsKey("weight") && + columns.containsKey("active") -> "members" columns.containsKey("categoryid") && columns.containsKey("categoryname") -> "categories" + columns.containsKey("paymentmodeid") && columns.containsKey("paymentmodename") -> "paymentmodes" columns.containsKey("exchange_rate") && columns.containsKey("currencyname") -> "currencies" else -> { onError(context.getString(R.string.import_error_header, row)) @@ -86,11 +109,19 @@ object ProjectImportHelper { } else { previousLineEmpty = false when (currentSection) { + "members" -> { + val name = line[columns["name"]!!].trim() + if (name.isNotEmpty()) { + membersWeight[name] = optional(line, columns, "weight").toDoubleOrNull() ?: 1.0 + membersActive[name] = optional(line, columns, "active") != "0" + parseHexColor(optional(line, columns, "color"))?.let { membersColor[name] = it } + } + } "categories" -> { - categories.add(DBCategory(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], line[columns["icon"]!!], line[columns["color"]!!])) + categories.add(DBCategory(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], optional(line, columns, "icon"), optional(line, columns, "color"))) } "paymentmodes" -> { - paymentModes.add(DBPaymentMode(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], line[columns["icon"]!!], line[columns["color"]!!])) + paymentModes.add(DBPaymentMode(0, line[columns["paymentmodeid"]!!].toLong(), 0, line[columns["paymentmodename"]!!], optional(line, columns, "icon"), optional(line, columns, "color"))) } "currencies" -> { val name = line[columns["currencyname"]!!] @@ -100,7 +131,22 @@ object ProjectImportHelper { } "bills" -> { val what = if (columns.containsKey("what")) line[columns["what"]!!] else "" - val comment = if (columns.containsKey("comment")) line[columns["comment"]!!] else "" + // Cospend url-encodes bill comments on export and marks trashed + // bills with a "deleted" column no other dialect has, so that column + // doubles as the marker for which comment encoding to expect. + val cospendDialect = columns.containsKey("deleted") + val comment = if (columns.containsKey("comment")) { + val raw = line[columns["comment"]!!] + if (cospendDialect) { + try { + URLDecoder.decode(raw, "UTF-8") + } catch (_: Exception) { + raw + } + } else raw + } else "" + val deleted = cospendDialect && + line[columns["deleted"]!!].trim().let { it.isNotEmpty() && it != "0" } val amount = if (columns.containsKey("amount")) line[columns["amount"]!!].toDouble() else 0.0 val timestamp: Long = when { columns.containsKey("timestamp") -> line[columns["timestamp"]!!].toLong() @@ -121,7 +167,12 @@ object ProjectImportHelper { val catId = if (columns.containsKey("categoryid") && line[columns["categoryid"]!!].isNotEmpty()) line[columns["categoryid"]!!].toLong() else 0L val pmId = if (columns.containsKey("paymentmodeid") && line[columns["paymentmodeid"]!!].isNotEmpty()) line[columns["paymentmodeid"]!!].toLong() else 0L val pm = if (columns.containsKey("paymentmode")) line[columns["paymentmode"]!!] else null - + // MoneyBuster's export only carries the legacy one-letter payment + // mode. Everything downstream keys off paymentModeId, so translate + // it back the same way the sync parser does. + val effectivePmId = + if (pmId != 0L) pmId else DBBill.oldPmIdToNew[pm] ?: DBBill.PAYMODE_ID_NONE + if (payerName.isNotEmpty()) { membersActive[payerName] = payerActive membersWeight[payerName] = payerWeight @@ -132,7 +183,7 @@ object ProjectImportHelper { return } - if (what != "deleteMeIfYouWant") { + if (what != "deleteMeIfYouWant" && !deleted) { billRemoteIdToOwerStr[row.toLong()] = owersStr val owersArray = owersStr.split(",").map { it.trim() }.filter { it.isNotEmpty() } for (ower in owersArray) { @@ -140,7 +191,7 @@ object ProjectImportHelper { membersWeight[ower] = 1.0 } } - bills.add(DBBill(0, row.toLong(), 0, 0, amount, timestamp, what, DBBill.STATE_OK, "n", pm, catId, comment, pmId)) + bills.add(DBBill(0, row.toLong(), 0, 0, amount, timestamp, what, DBBill.STATE_OK, "n", pm, catId, comment, effectivePmId)) billRemoteIdToPayerName[row.toLong()] = payerName } } @@ -168,13 +219,20 @@ object ProjectImportHelper { currencies.forEach { db.addCurrency(DBCurrency(0, 0, pid, it.name, it.exchangeRate, DBBill.STATE_OK)) } membersWeight.keys.forEach { mName -> - memberNameToId[mName] = db.addMember(DBMember(0, 0, pid, mName, membersActive[mName] ?: true, membersWeight[mName] ?: 1.0, DBBill.STATE_OK, null, null, null, null, null)) + val c = membersColor[mName] + memberNameToId[mName] = db.addMember(DBMember(0, 0, pid, mName, membersActive[mName] ?: true, membersWeight[mName] ?: 1.0, DBBill.STATE_OK, c?.first, c?.second, c?.third, null, null)) } bills.forEach { b -> val payerId = memberNameToId[billRemoteIdToPayerName[b.remoteId]] ?: 0L - val localCatId = catRemoteToLocal[b.categoryId] ?: 0L - val localPmId = pmRemoteToLocal[b.paymentModeId] ?: 0L + // Only custom labels are listed in the categories/paymentmodes sections. Built-in + // ones are referenced by their (negative) constant, which the UI resolves on its + // own, so those have to be kept rather than reset to "none". Unmapped positive + // ids are dropped instead, as they would collide with local ids. + val localCatId = catRemoteToLocal[b.categoryId] + ?: b.categoryId.takeIf { it < 0 } ?: 0L + val localPmId = pmRemoteToLocal[b.paymentModeId] + ?: b.paymentModeId.takeIf { it < 0 } ?: 0L val billId = db.addBill(DBBill(0, 0, pid, payerId, b.amount, b.timestamp, b.what, DBBill.STATE_OK, b.repeat, b.paymentMode, localCatId, b.comment, localPmId)) billRemoteIdToOwerStr[b.remoteId]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { ower -> memberNameToId[ower]?.let { owerId -> db.addBillower(billId, owerId) } diff --git a/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt b/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt index a9df23f..bc7c0dd 100644 --- a/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt +++ b/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt @@ -5,6 +5,9 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper object ExportUtil { + /** Quotes a field the way RFC4180 (and opencsv, and Cospend) expect. */ + private fun q(value: String?): String = "\"" + (value ?: "").replace("\"", "\"\"") + "\"" + @JvmStatic fun createExportContent(db: CowspentSQLiteOpenHelper, projectId: Long): String { var fileContent = "" @@ -18,8 +21,8 @@ object ExportUtil { } val bills = db.getBillsOfProject(projectId).toMutableList() - // write header - fileContent += "what,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,categoryid,paymentmode\n" + // write header. + fileContent += "what,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,categoryid,paymentmode,paymentmodeid,comment\n" // write members for (m in members) { @@ -41,15 +44,11 @@ object ExportUtil { val payerName = payer.name val payerWeight = payer.weight val payerActive = if (payer.isActivated) 1 else 0 - val billOwers = b.billOwers - var owersTxt = "" - for (bo in billOwers) { - owersTxt += membersById[bo.memberId]?.name + "," - } - owersTxt = owersTxt.replace(",$".toRegex(), "") - fileContent += "\"${b.what}\",${b.amount},${b.date},${b.timestamp},\"$payerName\"," + - "$payerWeight,$payerActive,\"$owersTxt\",${b.repeat},${b.categoryId}," + - "${b.paymentMode}\n" + val owersTxt = b.billOwers.mapNotNull { membersById[it.memberId]?.name }.joinToString(",") + fileContent += "${q(b.what)},${b.amount},${b.date},${b.timestamp},${q(payerName)}," + + "$payerWeight,$payerActive,${q(owersTxt)},${b.repeat ?: DBBill.NON_REPEATED}," + + "${b.categoryId},${b.paymentMode ?: DBBill.PAYMODE_NONE},${b.paymentModeId}," + + "${q(b.comment)}\n" } // write categories @@ -57,7 +56,16 @@ object ExportUtil { if (cats.isNotEmpty()) { fileContent += "\ncategoryname,categoryid,icon,color\n" for (cat in cats) { - fileContent += "\"${cat.name}\",${cat.id},\"${cat.icon}\",\"${cat.color}\"\n" + fileContent += "${q(cat.name)},${cat.id},${q(cat.icon)},${q(cat.color)}\n" + } + } + + // write payment modes + val pms = db.getPaymentModes(projectId) + if (pms.isNotEmpty()) { + fileContent += "\npaymentmodename,paymentmodeid,icon,color\n" + for (pm in pms) { + fileContent += "${q(pm.name)},${pm.id},${q(pm.icon)},${q(pm.color)}\n" } } @@ -67,9 +75,9 @@ object ExportUtil { project.currencyName!!.isNotEmpty() && project.currencyName != "null" ) { fileContent += "\ncurrencyname,exchange_rate\n" - fileContent += "\"${project.currencyName}\",1\n" + fileContent += "${q(project.currencyName)},1\n" for (cur in curs) { - fileContent += "\"${cur.name}\",${cur.exchangeRate}\n" + fileContent += "${q(cur.name)},${cur.exchangeRate}\n" } }