package space.bloggin.vellum import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import androidx.compose.runtime.mutableIntStateOf import java.time.LocalDate data class Note(val ref: Ref, val text: String, val updated: Long) data class Mark(val ref: Ref, val created: Long) /** Bookmarks, highlights, notes and reading-plan progress. Everything stays on the phone. */ class UserData private constructor(ctx: Context) : SQLiteOpenHelper(ctx, "user.db", null, 1) { /** bumps whenever anything changes, so the UI can recompose */ val rev = mutableIntStateOf(0) private val highlights = HashMap() private val notes = HashMap() private val bookmarks = HashMap() override fun onCreate(db: SQLiteDatabase) { db.execSQL("CREATE TABLE highlight(k INTEGER PRIMARY KEY, color INTEGER, created INTEGER)") db.execSQL("CREATE TABLE note(k INTEGER PRIMARY KEY, text TEXT, updated INTEGER)") db.execSQL("CREATE TABLE bookmark(k INTEGER PRIMARY KEY, created INTEGER)") db.execSQL("CREATE TABLE plan(id TEXT PRIMARY KEY, started TEXT)") db.execSQL("CREATE TABLE plan_day(plan TEXT, day INTEGER, done TEXT, PRIMARY KEY(plan,day))") db.execSQL("CREATE TABLE activity(date TEXT PRIMARY KEY)") } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {} init { val db = readableDatabase db.rawQuery("SELECT k,color FROM highlight", null).use { while (it.moveToNext()) highlights[it.getInt(0)] = it.getInt(1) } db.rawQuery("SELECT k,text,updated FROM note", null).use { while (it.moveToNext()) notes[it.getInt(0)] = Note(Ref.fromKey(it.getInt(0)), it.getString(1), it.getLong(2)) } db.rawQuery("SELECT k,created FROM bookmark", null).use { while (it.moveToNext()) bookmarks[it.getInt(0)] = it.getLong(1) } } private fun bump() { rev.intValue++ } // highlights: color index 1..6, 0 = none fun highlight(r: Ref): Int = highlights[r.key()] ?: 0 fun setHighlight(refs: Collection, color: Int) { val db = writableDatabase db.beginTransaction() try { for (r in refs) { if (color == 0) { db.delete("highlight", "k=?", arrayOf(r.key().toString())); highlights.remove(r.key()) } else { db.insertWithOnConflict("highlight", null, ContentValues().apply { put("k", r.key()); put("color", color); put("created", System.currentTimeMillis()) }, SQLiteDatabase.CONFLICT_REPLACE) highlights[r.key()] = color } } db.setTransactionSuccessful() } finally { db.endTransaction() } bump() } fun allHighlights(): List> = highlights.entries.sortedBy { it.key }.map { Ref.fromKey(it.key) to it.value } fun note(r: Ref): Note? = notes[r.key()] fun setNote(r: Ref, text: String) { val db = writableDatabase if (text.isBlank()) { db.delete("note", "k=?", arrayOf(r.key().toString())); notes.remove(r.key()) } else { val now = System.currentTimeMillis() db.insertWithOnConflict("note", null, ContentValues().apply { put("k", r.key()); put("text", text); put("updated", now) }, SQLiteDatabase.CONFLICT_REPLACE) notes[r.key()] = Note(r, text, now) } bump() } fun allNotes(): List = notes.values.sortedByDescending { it.updated } fun isBookmarked(r: Ref) = bookmarks.containsKey(r.key()) fun toggleBookmark(r: Ref) { val db = writableDatabase if (isBookmarked(r)) { db.delete("bookmark", "k=?", arrayOf(r.key().toString())); bookmarks.remove(r.key()) } else { val now = System.currentTimeMillis() db.insertWithOnConflict("bookmark", null, ContentValues().apply { put("k", r.key()); put("created", now) }, SQLiteDatabase.CONFLICT_REPLACE) bookmarks[r.key()] = now } bump() } fun allBookmarks(): List = bookmarks.entries.sortedByDescending { it.value }.map { Mark(Ref.fromKey(it.key), it.value) } // ---------------- reading plans ---------------- fun planStarted(id: String): LocalDate? = readableDatabase.rawQuery("SELECT started FROM plan WHERE id=?", arrayOf(id)).use { if (it.moveToFirst()) LocalDate.parse(it.getString(0)) else null } fun startPlan(id: String) { val db = writableDatabase db.delete("plan_day", "plan=?", arrayOf(id)) db.insertWithOnConflict("plan", null, ContentValues().apply { put("id", id); put("started", LocalDate.now().toString()) }, SQLiteDatabase.CONFLICT_REPLACE) bump() } fun stopPlan(id: String) { writableDatabase.delete("plan", "id=?", arrayOf(id)) writableDatabase.delete("plan_day", "plan=?", arrayOf(id)) bump() } fun daysDone(id: String): Set = readableDatabase.rawQuery("SELECT day FROM plan_day WHERE plan=?", arrayOf(id)).use { c -> buildSet { while (c.moveToNext()) add(c.getInt(0)) } } fun setDayDone(id: String, day: Int, done: Boolean) { val db = writableDatabase if (done) { db.insertWithOnConflict("plan_day", null, ContentValues().apply { put("plan", id); put("day", day); put("done", LocalDate.now().toString()) }, SQLiteDatabase.CONFLICT_REPLACE) db.insertWithOnConflict("activity", null, ContentValues().apply { put("date", LocalDate.now().toString()) }, SQLiteDatabase.CONFLICT_IGNORE) } else db.delete("plan_day", "plan=? AND day=?", arrayOf(id, day.toString())) bump() } /** Consecutive days (ending today, or yesterday if today isn't done yet) with a finished plan reading. */ fun streak(): Int { val dates = readableDatabase.rawQuery("SELECT date FROM activity", null).use { c -> buildSet { while (c.moveToNext()) add(c.getString(0)) } } var d = LocalDate.now() if (!dates.contains(d.toString())) d = d.minusDays(1) var n = 0 while (dates.contains(d.toString())) { n++; d = d.minusDays(1) } return n } fun bestStreak(): Int { val dates = readableDatabase.rawQuery("SELECT date FROM activity ORDER BY date", null).use { c -> buildList { while (c.moveToNext()) add(LocalDate.parse(c.getString(0))) } } var best = 0; var cur = 0; var prev: LocalDate? = null for (d in dates) { cur = if (prev != null && prev.plusDays(1) == d) cur + 1 else 1 best = maxOf(best, cur); prev = d } return best } companion object { @Volatile private var instance: UserData? = null fun get(ctx: Context): UserData = instance ?: synchronized(this) { instance ?: UserData(ctx.applicationContext).also { instance = it } } } }