package com.vgmlr.kerf
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import androidx.core.net.toUri
object KerfUtils {
val UrlRegex = Regex("""(https?://\S+|content://\S+|file://\S+|/storage/\S+|/sdcard/\S+)""")
val ItemRegex = Regex("""^(\d+)\.""")
const val DEFAULT_PREFIX = "1. "
fun parseKerfText(text: String): Pair<String, String>? {
if (!text.startsWith("Kerf: ")) return null
val doubleNewlineIndex = text.indexOf("\n\n")
val title = if (doubleNewlineIndex != -1) {
text.substring(6, doubleNewlineIndex).trim()
} else {
text.substring(6).trim()
}
val content = if (doubleNewlineIndex != -1) {
text.substring(doubleNewlineIndex + 2).ifBlank { DEFAULT_PREFIX }
} else {
DEFAULT_PREFIX
}
return title to content
}
fun countNumberedItems(content: String): Int {
return content.lines().count { line ->
ItemRegex.find(line.trim()) != null
}
}
fun deleteAndReindex(content: String, lineIndex: Int): String {
val lines = content.lines()
if (lineIndex < 0 || lineIndex >= lines.size) return content
val isDeletingNumberedItem = ItemRegex.find(lines[lineIndex].trim()) != null
if (!isDeletingNumberedItem) {
val mutableLines = lines.toMutableList()
mutableLines.removeAt(lineIndex)
var currentNum = 1
return mutableLines.joinToString("\n") { line ->
if (ItemRegex.find(line.trim()) != null) {
val cleanLine = line.trim().replaceFirst(ItemRegex, "").trim()
"$currentNum. $cleanLine".also { currentNum++ }
} else line
}
}
val parsedBlocks = mutableListOf<MutableList<String>>()
var currentBlock = mutableListOf<String>()
var targetBlockIndex = -1
for (i in lines.indices) {
val line = lines[i]
if (ItemRegex.find(line.trim()) != null) {
if (currentBlock.isNotEmpty()) parsedBlocks.add(currentBlock)
currentBlock = mutableListOf(line)
if (i == lineIndex) targetBlockIndex = parsedBlocks.size
} else {
currentBlock.add(line)
}
}
if (currentBlock.isNotEmpty()) parsedBlocks.add(currentBlock)
if (targetBlockIndex != -1) {
parsedBlocks.removeAt(targetBlockIndex)
}
var currentNum = 1
val newContent = StringBuilder()
for (i in parsedBlocks.indices) {
val block = parsedBlocks[i]
val titleLine = block.first().trim()
val cleanTitle = titleLine.replaceFirst(ItemRegex, "").trim()
newContent.append("$currentNum. $cleanTitle\n")
currentNum++
var lastNonEmpty = block.size - 1
while (lastNonEmpty > 0 && block[lastNonEmpty].trim().isEmpty()) {
lastNonEmpty--
}
val hasTextComment = lastNonEmpty > 0
for (j in 1..lastNonEmpty) {
newContent.append(block[j]).append("\n")
}
if (i < parsedBlocks.size - 1) {
if (hasTextComment) {
newContent.append("\n")
} else {
newContent.append("\n\n")
}
}
}
val finalResult = newContent.toString()
if (finalResult.isEmpty()) return DEFAULT_PREFIX + "\n"
return finalResult
}
fun cleanupCache(context: Context) {
try {
context.cacheDir.listFiles { _, name -> name.endsWith(".kerf") }?.forEach { it.delete() }
} catch (_: Exception) {}
}
fun shareReplies(context: Context, title: String, repliesRaw: String?) {
if (repliesRaw == null) return
val json = JSONObject(repliesRaw)
val keys = mutableListOf<Int>()
val it = json.keys()
while (it.hasNext()) {
val key = it.next().toIntOrNull()
if (key != null) keys.add(key)
}
keys.sort()
val list = keys.joinToString("\n\n") { key ->
val text = json.getString(key.toString())
if (text.startsWith("$key. ")) text else "$key. $text"
}
if (list.isNotBlank()) {
val finalContent = if (title.isNotBlank()) "Kerf: Reply: $title\n\n$list" else list
shareNote(context, finalContent)
}
}
fun importNoteFromUri(context: Context, uri: Uri): Pair<String, String>? {
return try {
val contentResolver = context.contentResolver
val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && cursor.moveToFirst()) {
cursor.getString(nameIndex)
} else null
} ?: "Imported List"
val title = fileName.removeSuffix(".kerf").replace("_", " ")
val content = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
if (content != null) title to content else null
} catch (_: Exception) {
null
}
}
fun formatLastSharedDate(timestamp: Long?): String {
if (timestamp == null || timestamp == 0L) return "n/a"
val cal = Calendar.getInstance().apply { timeInMillis = timestamp }
val gYear = cal.get(Calendar.YEAR)
val isBeforeMarch20 = (cal.get(Calendar.MONTH) < Calendar.MARCH) ||
(cal.get(Calendar.MONTH) == Calendar.MARCH && cal.get(Calendar.DAY_OF_MONTH) < 20)
val cycleStartYear = if (isBeforeMarch20) gYear - 1 else gYear
val altYear = cycleStartYear % 100
val cycleStart = Calendar.getInstance().apply {
set(Calendar.YEAR, cycleStartYear); set(Calendar.MONTH, Calendar.MARCH)
set(Calendar.DAY_OF_MONTH, 20); set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
}
val diff = timestamp - cycleStart.timeInMillis
val altDayOfYear = TimeUnit.MILLISECONDS.toDays(diff) + 1
val time = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
val gregDate = SimpleDateFormat("dd MMM", Locale.getDefault()).format(Date(timestamp))
return "$time $altDayOfYear 0$altYear ($gregDate)"
}
fun getLocalFilePath(context: Context, uri: Uri): String? {
if (uri.scheme == "file") {
return uri.path
} else if (uri.scheme == "content") {
try {
context.contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val columnIndex = cursor.getColumnIndex("_data")
if (columnIndex != -1) {
return cursor.getString(columnIndex)
}
}
}
} catch (_: Exception) {}
}
return null
}
fun isKerfFile(context: Context, uri: Uri): Boolean {
if (uri.path?.endsWith(".kerf", ignoreCase = true) == true) return true
try {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
val fileName = cursor.getString(nameIndex)
if (fileName != null && fileName.endsWith(".kerf", ignoreCase = true)) {
return true
}
}
}
}
} catch (_: Exception) {}
return false
}
fun openAddress(context: Context, address: String) {
try {
if (address.startsWith("http://") || address.startsWith("https://")) {
val intent = Intent(Intent.ACTION_VIEW, address.toUri()).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} else {
val uri = if (address.startsWith("content://") || address.startsWith("file://")) {
address.toUri()
} else if (address.startsWith("/")) {
Uri.fromFile(java.io.File(address))
} else {
address.toUri()
}
val intent = Intent(Intent.ACTION_VIEW).apply {
val mimeType = context.contentResolver.getType(uri) ?: run {
val path = uri.path ?: address
val ext = path.substringAfterLast('.', "").lowercase()
android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
}
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val oldPolicy = android.os.StrictMode.getVmPolicy()
try {
android.os.StrictMode.setVmPolicy(android.os.StrictMode.VmPolicy.Builder().build())
context.startActivity(intent)
} catch (_: Exception) {
val fallbackIntent = Intent(Intent.ACTION_VIEW, uri).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(fallbackIntent)
} finally {
android.os.StrictMode.setVmPolicy(oldPolicy)
}
}
} catch (_: Exception) {}
}
}
fun shareNote(context: Context, content: String) {
val shareIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, content)
}
context.startActivity(Intent.createChooser(shareIntent, "Share List"))
}