Import/Export Improvements

This commit is contained in:
2026-09-09 08:53:41 +02:00
parent 561d3a4ed8
commit 2ec5c00df1
2 changed files with 89 additions and 23 deletions
@@ -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<String>, columns: Map<String, Int>, 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<Int, Int, Int>? {
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<DBBill>()
val membersActive = mutableMapOf<String, Boolean>()
val membersWeight = mutableMapOf<String, Double>()
val membersColor = mutableMapOf<String, Triple<Int, Int, Int>>()
val billRemoteIdToPayerName = mutableMapOf<Long, String>()
val billRemoteIdToOwerStr = mutableMapOf<Long, String>()
@@ -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,6 +167,11 @@ 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
@@ -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) }
@@ -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"
}
}