Compare commits
31 Commits
5f387dfb4a
...
v1,0
| Author | SHA1 | Date | |
|---|---|---|---|
| fb2474ba21 | |||
| 3c585c2f6b | |||
| 37d424ef60 | |||
| 66155f6141 | |||
| 4ea8e1c2ff | |||
| 65cb8ee2d5 | |||
| fd28a45cd7 | |||
| df9485bd98 | |||
| b907fdbbc3 | |||
| 0799595b26 | |||
| d606a59a92 | |||
| b44f6866cd | |||
| ba6e0c2364 | |||
| ff5503e30c | |||
| dcf74007e7 | |||
| 2ef56dc78b | |||
| c39ab434b7 | |||
| 2a963d9380 | |||
| 6fb4e65a20 | |||
| 0327bc1d78 | |||
| b1265f2c2d | |||
| f7f9f5f3be | |||
| 7671416038 | |||
| 50b35a0025 | |||
| 779ad6c737 | |||
| 6dc43ad411 | |||
| 7ff9ddd6c2 | |||
| 18e512f5ac | |||
| 19b05a89f9 | |||
| 00b95be3ed | |||
| e873a0055f |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,12 +16,14 @@ android/**/build/
|
|||||||
android/captures/
|
android/captures/
|
||||||
*.apk
|
*.apk
|
||||||
*.aab
|
*.aab
|
||||||
|
*.hprof
|
||||||
|
|
||||||
# ide
|
# ide
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
# os
|
# os and emacs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/archive
|
||||||
|
|||||||
80
README.md
80
README.md
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
open app.
|
open app.
|
||||||
type, optionally mark as todo, optionally add tags, save.
|
type, optionally mark as todo, optionally add tags, save.
|
||||||
if home server accessible, post unsynced captures to lan-only service and appended to `phone.org`.
|
if home server accessible, post unsynced captures to lan-only service and appended to `synq.org`.
|
||||||
|
|
||||||
## non-goals
|
## non-goals
|
||||||
|
|
||||||
- no org parser on android
|
- no org parser on android
|
||||||
- no agenda on android
|
- no agenda on android
|
||||||
- no sync provider dependency
|
- no sync provider dependency
|
||||||
@@ -157,6 +156,83 @@ synq/
|
|||||||
5. add history screen and resend handling.
|
5. add history screen and resend handling.
|
||||||
6. polish launch speed and widget/share-target only after core path works.
|
6. polish launch speed and widget/share-target only after core path works.
|
||||||
|
|
||||||
|
## build & deploy
|
||||||
|
|
||||||
|
### server (docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# build
|
||||||
|
docker build -t synq-server ./server
|
||||||
|
|
||||||
|
# run (replace paths and token as needed)
|
||||||
|
docker run -d --name synq --restart=unless-stopped \
|
||||||
|
-p 8765:8765 \
|
||||||
|
-v /mnt/user/synq/data:/data \
|
||||||
|
synq-server
|
||||||
|
```
|
||||||
|
|
||||||
|
the server generates a random token on first start and logs it prominently. copy it into the android app settings. to use a fixed token instead, pass `-e PHONE_CAPTURE_TOKEN=yourtoken`.
|
||||||
|
|
||||||
|
to rebuild after a `git pull`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker build -t synq-server ./server
|
||||||
|
docker stop synq && docker rm synq
|
||||||
|
# re-run the docker run command above
|
||||||
|
```
|
||||||
|
|
||||||
|
### android
|
||||||
|
|
||||||
|
open `android/` in Android Studio and hit **Sync**. then either:
|
||||||
|
|
||||||
|
- **run on device/emulator directly** from Android Studio (▶), or
|
||||||
|
- **build a release APK** from the terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
./gradlew assembleRelease
|
||||||
|
# output: app/build/outputs/apk/release/app-release-unsigned.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
sideload to a connected device:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install app/build/outputs/apk/release/app-release-unsigned.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
### releasing on gitea
|
||||||
|
|
||||||
|
tag the commit and push the tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.0.0
|
||||||
|
git push gitea v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
then go to **Releases → New Release** in the Gitea UI, pick the tag, and drag the APK into the assets box. anyone on the LAN can download it from there.
|
||||||
|
|
||||||
|
## backup
|
||||||
|
|
||||||
|
### what to back up
|
||||||
|
|
||||||
|
the server writes to one directory (the `/data` volume). back up the whole thing:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/synq.org ← the canonical org capture file
|
||||||
|
/data/capture.sqlite3 ← idempotency store (dedup ids)
|
||||||
|
/data/token.txt ← auto-generated token (if not using PHONE_CAPTURE_TOKEN env var)
|
||||||
|
```
|
||||||
|
|
||||||
|
on unraid the host path is whatever you mapped in compose, e.g. `/mnt/user/ben/synq/phone-capture`.
|
||||||
|
|
||||||
|
### restore behavior
|
||||||
|
|
||||||
|
- restore the `/data` volume to a new container and start it. the server will pick up where it left off.
|
||||||
|
- `synq.org` is append-only plain text — it is human-readable and recoverable even without the sqlite db.
|
||||||
|
- if `capture.sqlite3` is lost but `synq.org` is intact, the server will accept re-posted captures that were already in the org file (no dedup). the android app marks them synced either way (`already_seen` or `accepted`), so the only side effect is duplicate org entries for anything re-synced. restore the db from backup to avoid this.
|
||||||
|
- if `token.txt` is lost, delete it and let the server generate a new one on next start, then update the app settings.
|
||||||
|
|
||||||
## v2 parking lot
|
## v2 parking lot
|
||||||
|
|
||||||
- android share target
|
- android share target
|
||||||
|
|||||||
66
android/app/build.gradle.kts
Normal file
66
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
id("com.google.devtools.ksp")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "me.hgsky.synq"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "me.hgsky.synq"
|
||||||
|
minSdk = 31
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "21"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
|
||||||
|
implementation(composeBom)
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.8.4")
|
||||||
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
|
|
||||||
|
val room = "2.6.1"
|
||||||
|
implementation("androidx.room:room-runtime:$room")
|
||||||
|
implementation("androidx.room:room-ktx:$room")
|
||||||
|
ksp("androidx.room:room-compiler:$room")
|
||||||
|
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.1")
|
||||||
|
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
}
|
||||||
30
android/app/src/main/AndroidManifest.xml
Normal file
30
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".SynqApp"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.Synq"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
57
android/app/src/main/kotlin/me/hgsky/synq/MainActivity.kt
Normal file
57
android/app/src/main/kotlin/me/hgsky/synq/MainActivity.kt
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package me.hgsky.synq
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import me.hgsky.synq.ui.capture.CaptureScreen
|
||||||
|
import me.hgsky.synq.ui.history.HistoryScreen
|
||||||
|
import me.hgsky.synq.ui.settings.SettingsScreen
|
||||||
|
import me.hgsky.synq.ui.theme.SynqTheme
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val sharedText = if (intent.action == Intent.ACTION_SEND)
|
||||||
|
intent.getStringExtra(Intent.EXTRA_TEXT) else null
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
SynqTheme { SynqNav(sharedText = sharedText) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SynqNav(sharedText: String? = null) {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
val startRoute = if (sharedText != null) {
|
||||||
|
"capture/${URLEncoder.encode(sharedText, "UTF-8")}"
|
||||||
|
} else {
|
||||||
|
"capture"
|
||||||
|
}
|
||||||
|
|
||||||
|
NavHost(navController = nav, startDestination = startRoute) {
|
||||||
|
composable("capture") { CaptureScreen(nav) }
|
||||||
|
composable(
|
||||||
|
route = "capture/{prefill}",
|
||||||
|
arguments = listOf(navArgument("prefill") { type = NavType.StringType }),
|
||||||
|
) { back ->
|
||||||
|
val prefill = back.arguments?.getString("prefill")
|
||||||
|
?.let { URLDecoder.decode(it, "UTF-8") }
|
||||||
|
CaptureScreen(nav, prefill = prefill)
|
||||||
|
}
|
||||||
|
composable("history") { HistoryScreen(nav) }
|
||||||
|
composable("settings") { SettingsScreen(nav) }
|
||||||
|
}
|
||||||
|
}
|
||||||
16
android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt
Normal file
16
android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package me.hgsky.synq
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import me.hgsky.synq.data.SettingsRepository
|
||||||
|
import me.hgsky.synq.data.db.CaptureDatabase
|
||||||
|
import me.hgsky.synq.data.sync.SyncWorker
|
||||||
|
|
||||||
|
class SynqApp : Application() {
|
||||||
|
val db by lazy { CaptureDatabase.get(this) }
|
||||||
|
val settings by lazy { SettingsRepository(this) }
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
SyncWorker.schedule(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
android/app/src/main/kotlin/me/hgsky/synq/data/CaptureId.kt
Normal file
13
android/app/src/main/kotlin/me/hgsky/synq/data/CaptureId.kt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package me.hgsky.synq.data
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
private val DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
|
||||||
|
private const val CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
|
fun generateCaptureId(): String {
|
||||||
|
val ts = LocalDateTime.now().format(DATE_FMT)
|
||||||
|
val rand = (1..4).map { CHARS.random() }.joinToString("")
|
||||||
|
return "phone-$ts-$rand"
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package me.hgsky.synq.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.dataStore by preferencesDataStore("synq_settings")
|
||||||
|
|
||||||
|
data class SynqSettings(
|
||||||
|
val serverUrl: String = "http://jeeves.mother:8765",
|
||||||
|
val token: String = "",
|
||||||
|
val deviceLabel: String = "android",
|
||||||
|
val syncIntervalMinutes: Int = 15,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SettingsRepository(private val context: Context) {
|
||||||
|
|
||||||
|
private val KEY_URL = stringPreferencesKey("server_url")
|
||||||
|
private val KEY_TOKEN = stringPreferencesKey("token")
|
||||||
|
private val KEY_DEVICE = stringPreferencesKey("device_label")
|
||||||
|
private val KEY_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
||||||
|
|
||||||
|
val settings: Flow<SynqSettings> = context.dataStore.data.map { prefs ->
|
||||||
|
SynqSettings(
|
||||||
|
serverUrl = prefs[KEY_URL] ?: "http://jeeves.mother:8765",
|
||||||
|
token = prefs[KEY_TOKEN] ?: "",
|
||||||
|
deviceLabel = prefs[KEY_DEVICE] ?: "android",
|
||||||
|
syncIntervalMinutes = prefs[KEY_INTERVAL] ?: 15,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun save(settings: SynqSettings) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_URL] = settings.serverUrl
|
||||||
|
prefs[KEY_TOKEN] = settings.token
|
||||||
|
prefs[KEY_DEVICE] = settings.deviceLabel
|
||||||
|
prefs[KEY_INTERVAL] = settings.syncIntervalMinutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package me.hgsky.synq.data.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import me.hgsky.synq.data.SynqSettings
|
||||||
|
import me.hgsky.synq.data.db.CaptureEntity
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
sealed class PostResult {
|
||||||
|
data class Accepted(val id: String) : PostResult()
|
||||||
|
data class AlreadySeen(val id: String) : PostResult()
|
||||||
|
data class Failed(val id: String, val error: String) : PostResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SynqApiClient {
|
||||||
|
|
||||||
|
private val http = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun checkHealth(serverUrl: String): Boolean = try {
|
||||||
|
val req = Request.Builder().url("$serverUrl/health").get().build()
|
||||||
|
http.newCall(req).execute().use { it.isSuccessful }
|
||||||
|
} catch (_: IOException) {
|
||||||
|
false
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postCapture(capture: CaptureEntity, settings: SynqSettings): PostResult = try {
|
||||||
|
val tags = try {
|
||||||
|
Json.decodeFromString<List<String>>(capture.tagsJson)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyJson = JSONObject().apply {
|
||||||
|
put("id", capture.id)
|
||||||
|
put("created_at", capture.createdAt)
|
||||||
|
put("kind", capture.kind)
|
||||||
|
put("body", capture.body)
|
||||||
|
put("tags", JSONArray(tags))
|
||||||
|
put("device", capture.device)
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("${settings.serverUrl}/capture")
|
||||||
|
.header("Authorization", "Bearer ${settings.token}")
|
||||||
|
.post(bodyJson.toRequestBody("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
http.newCall(req).execute().use { response ->
|
||||||
|
when {
|
||||||
|
response.code == 401 -> PostResult.Failed(capture.id, "unauthorized (check token in settings)")
|
||||||
|
!response.isSuccessful -> PostResult.Failed(capture.id, "server error ${response.code}")
|
||||||
|
else -> {
|
||||||
|
val json = JSONObject(response.body?.string() ?: "{}")
|
||||||
|
if (json.optString("status") == "already_seen")
|
||||||
|
PostResult.AlreadySeen(capture.id)
|
||||||
|
else
|
||||||
|
PostResult.Accepted(capture.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
PostResult.Failed(capture.id, e.message ?: "network error")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
PostResult.Failed(capture.id, e.message ?: "unexpected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package me.hgsky.synq.data.db
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CaptureDao {
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(capture: CaptureEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(capture: CaptureEntity)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM captures ORDER BY createdAt DESC")
|
||||||
|
fun observeAll(): Flow<List<CaptureEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM captures WHERE status IN ('pending', 'failed') ORDER BY createdAt ASC")
|
||||||
|
suspend fun getPendingAndFailed(): List<CaptureEntity>
|
||||||
|
|
||||||
|
@Query("UPDATE captures SET status = :status, lastError = :error WHERE id = :id")
|
||||||
|
suspend fun updateStatus(id: String, status: String, error: String?)
|
||||||
|
|
||||||
|
@Query("UPDATE captures SET status = 'synced', syncedAt = :syncedAt, lastError = NULL WHERE id = :id")
|
||||||
|
suspend fun markSynced(id: String, syncedAt: String)
|
||||||
|
|
||||||
|
@Query("SELECT MAX(syncedAt) FROM captures WHERE syncedAt IS NOT NULL")
|
||||||
|
fun getLastSyncedAt(): Flow<String?>
|
||||||
|
|
||||||
|
@Query("SELECT tagsJson FROM captures WHERE tagsJson != '[]' AND tagsJson != '' ORDER BY createdAt DESC LIMIT 100")
|
||||||
|
fun getRecentTagsJson(): Flow<List<String>>
|
||||||
|
|
||||||
|
@Query("UPDATE captures SET body = :body WHERE id = :id")
|
||||||
|
suspend fun updateBody(id: String, body: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package me.hgsky.synq.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
@Database(entities = [CaptureEntity::class], version = 1, exportSchema = false)
|
||||||
|
abstract class CaptureDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun captureDao(): CaptureDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile private var instance: CaptureDatabase? = null
|
||||||
|
|
||||||
|
fun get(context: Context): CaptureDatabase = instance ?: synchronized(this) {
|
||||||
|
instance ?: Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
CaptureDatabase::class.java,
|
||||||
|
"synq.db",
|
||||||
|
).build().also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package me.hgsky.synq.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "captures")
|
||||||
|
data class CaptureEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val createdAt: String,
|
||||||
|
val kind: String, // "note" | "todo"
|
||||||
|
val body: String,
|
||||||
|
val tagsJson: String, // JSON array, e.g. ["home","errands"]
|
||||||
|
val device: String,
|
||||||
|
val status: String, // "pending" | "syncing" | "synced" | "failed"
|
||||||
|
val syncedAt: String? = null,
|
||||||
|
val lastError: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package me.hgsky.synq.data.sync
|
||||||
|
|
||||||
|
import me.hgsky.synq.data.SynqSettings
|
||||||
|
import me.hgsky.synq.data.api.PostResult
|
||||||
|
import me.hgsky.synq.data.api.SynqApiClient
|
||||||
|
import me.hgsky.synq.data.db.CaptureDao
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
suspend fun syncPending(dao: CaptureDao, client: SynqApiClient, settings: SynqSettings) {
|
||||||
|
val pending = dao.getPendingAndFailed()
|
||||||
|
for (capture in pending) {
|
||||||
|
dao.updateStatus(capture.id, "syncing", null)
|
||||||
|
val result = client.postCapture(capture, settings)
|
||||||
|
when (result) {
|
||||||
|
is PostResult.Accepted, is PostResult.AlreadySeen -> {
|
||||||
|
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||||
|
dao.markSynced(capture.id, now)
|
||||||
|
}
|
||||||
|
is PostResult.Failed -> dao.updateStatus(capture.id, "failed", result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package me.hgsky.synq.data.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import me.hgsky.synq.SynqApp
|
||||||
|
import me.hgsky.synq.data.api.SynqApiClient
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val app = applicationContext as SynqApp
|
||||||
|
val settings = app.settings.settings.first()
|
||||||
|
val client = SynqApiClient()
|
||||||
|
|
||||||
|
if (!client.checkHealth(settings.serverUrl)) return Result.success()
|
||||||
|
|
||||||
|
syncPending(app.db.captureDao(), client, settings)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "synq_periodic_sync"
|
||||||
|
|
||||||
|
fun schedule(context: Context, intervalMinutes: Int = 15) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||||
|
intervalMinutes.toLong().coerceAtLeast(15), TimeUnit.MINUTES,
|
||||||
|
)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package me.hgsky.synq.ui.capture
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CaptureScreen(
|
||||||
|
nav: NavController,
|
||||||
|
prefill: String? = null,
|
||||||
|
vm: CaptureViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val recentTags by vm.recentTags.collectAsState()
|
||||||
|
val lastSyncedAt by vm.lastSyncedAt.collectAsState()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
LaunchedEffect(prefill) {
|
||||||
|
if (!prefill.isNullOrEmpty()) vm.setBody(prefill)
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeTags = remember(state.tags) {
|
||||||
|
state.tags.split(Regex("[,\\s]+")).filter { it.isNotEmpty() }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastSyncedText = remember(lastSyncedAt) {
|
||||||
|
lastSyncedAt?.let { iso ->
|
||||||
|
runCatching {
|
||||||
|
val dt = OffsetDateTime.parse(iso)
|
||||||
|
"synced " + dt.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT))
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("synq") },
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { vm.syncNow() },
|
||||||
|
enabled = !state.isSyncing,
|
||||||
|
) {
|
||||||
|
if (state.isSyncing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Default.Sync, contentDescription = "sync now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = { nav.navigate("history") }) {
|
||||||
|
Icon(Icons.Default.History, contentDescription = "history")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { nav.navigate("settings") }) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = "settings")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.body,
|
||||||
|
onValueChange = vm::setBody,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
placeholder = { Text("capture something…") },
|
||||||
|
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
|
||||||
|
maxLines = Int.MAX_VALUE,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text("note", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Switch(
|
||||||
|
checked = state.isTodo,
|
||||||
|
onCheckedChange = vm::setTodo,
|
||||||
|
)
|
||||||
|
Text("todo", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.tags,
|
||||||
|
onValueChange = vm::setTags,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("tags — space or comma separated") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (recentTags.isNotEmpty()) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
recentTags.forEach { tag ->
|
||||||
|
FilterChip(
|
||||||
|
selected = tag in activeTags,
|
||||||
|
onClick = { vm.toggleTag(tag) },
|
||||||
|
label = { Text(tag, style = MaterialTheme.typography.labelSmall) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSyncedText != null) {
|
||||||
|
Text(
|
||||||
|
lastSyncedText,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { vm.save() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = state.body.isNotBlank(),
|
||||||
|
) { Text("save") }
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { vm.save { (context as? ComponentActivity)?.finish() } },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = state.body.isNotBlank(),
|
||||||
|
) { Text("save & close") }
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package me.hgsky.synq.ui.capture
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import me.hgsky.synq.SynqApp
|
||||||
|
import me.hgsky.synq.data.api.SynqApiClient
|
||||||
|
import me.hgsky.synq.data.generateCaptureId
|
||||||
|
import me.hgsky.synq.data.db.CaptureEntity
|
||||||
|
import me.hgsky.synq.data.sync.syncPending
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
data class CaptureUiState(
|
||||||
|
val body: String = "",
|
||||||
|
val isTodo: Boolean = false,
|
||||||
|
val tags: String = "",
|
||||||
|
val isSyncing: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class CaptureViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
private val synqApp = app as SynqApp
|
||||||
|
private val dao = synqApp.db.captureDao()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(CaptureUiState())
|
||||||
|
val state: StateFlow<CaptureUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
val recentTags: StateFlow<List<String>> = dao.getRecentTagsJson()
|
||||||
|
.map { jsonList ->
|
||||||
|
val seen = LinkedHashSet<String>()
|
||||||
|
for (json in jsonList) {
|
||||||
|
runCatching { Json.decodeFromString<List<String>>(json) }
|
||||||
|
.getOrElse { emptyList() }
|
||||||
|
.forEach { seen.add(it) }
|
||||||
|
}
|
||||||
|
seen.take(20).toList()
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
|
val lastSyncedAt: StateFlow<String?> = dao.getLastSyncedAt()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
|
fun setBody(v: String) { _state.value = _state.value.copy(body = v) }
|
||||||
|
fun setTodo(v: Boolean) { _state.value = _state.value.copy(isTodo = v) }
|
||||||
|
fun setTags(v: String) { _state.value = _state.value.copy(tags = v) }
|
||||||
|
|
||||||
|
fun toggleTag(tag: String) {
|
||||||
|
val current = parseTags(_state.value.tags).toMutableList()
|
||||||
|
if (current.contains(tag)) current.remove(tag) else current.add(tag)
|
||||||
|
_state.value = _state.value.copy(tags = current.joinToString(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(onDone: () -> Unit = {}) {
|
||||||
|
val s = _state.value
|
||||||
|
val body = s.body.trim()
|
||||||
|
if (body.isEmpty()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val tags = parseTags(s.tags)
|
||||||
|
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||||
|
dao.insert(
|
||||||
|
CaptureEntity(
|
||||||
|
id = generateCaptureId(),
|
||||||
|
createdAt = now,
|
||||||
|
kind = if (s.isTodo) "todo" else "note",
|
||||||
|
body = body,
|
||||||
|
tagsJson = Json.encodeToString(tags),
|
||||||
|
device = "android",
|
||||||
|
status = "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_state.value = _state.value.copy(body = "", tags = "")
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncNow() {
|
||||||
|
if (_state.value.isSyncing) return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_state.value = _state.value.copy(isSyncing = true)
|
||||||
|
try {
|
||||||
|
val settings = synqApp.settings.settings.first()
|
||||||
|
val client = SynqApiClient()
|
||||||
|
if (client.checkHealth(settings.serverUrl)) {
|
||||||
|
syncPending(dao, client, settings)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_state.value = _state.value.copy(isSyncing = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTags(raw: String): List<String> =
|
||||||
|
raw.split(Regex("[,\\s]+"))
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package me.hgsky.synq.ui.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import me.hgsky.synq.data.db.CaptureEntity
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
|
||||||
|
val captures by vm.captures.collectAsState(initial = emptyList())
|
||||||
|
val lastSyncedAt by vm.lastSyncedAt.collectAsState()
|
||||||
|
|
||||||
|
val lastSyncedText = remember(lastSyncedAt) {
|
||||||
|
lastSyncedAt?.let { iso ->
|
||||||
|
runCatching {
|
||||||
|
val dt = OffsetDateTime.parse(iso)
|
||||||
|
"last synced " + dt.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT))
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("history")
|
||||||
|
if (lastSyncedText != null) {
|
||||||
|
Text(
|
||||||
|
lastSyncedText,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { nav.navigateUp() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (captures.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { Text("no captures yet") }
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(captures, key = { it.id }) { capture ->
|
||||||
|
CaptureRow(
|
||||||
|
capture = capture,
|
||||||
|
onRetry = { vm.retryCapture(capture) },
|
||||||
|
onEditConfirm = { newBody -> vm.updateBody(capture, newBody) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CaptureRow(
|
||||||
|
capture: CaptureEntity,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onEditConfirm: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by rememberSaveable(capture.id) { mutableStateOf(false) }
|
||||||
|
var showEdit by remember { mutableStateOf(false) }
|
||||||
|
var editBody by remember(capture.body) { mutableStateOf(capture.body) }
|
||||||
|
|
||||||
|
val statusColor = when (capture.status) {
|
||||||
|
"synced" -> Color(0xFF2E7D32)
|
||||||
|
"failed" -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
val editable = capture.status == "pending" || capture.status == "failed"
|
||||||
|
|
||||||
|
if (showEdit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showEdit = false },
|
||||||
|
title = { Text("edit capture") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = editBody,
|
||||||
|
onValueChange = { editBody = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("body") },
|
||||||
|
minLines = 3,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onEditConfirm(editBody)
|
||||||
|
showEdit = false
|
||||||
|
},
|
||||||
|
enabled = editBody.isNotBlank(),
|
||||||
|
) { Text("save") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showEdit = false }) { Text("cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded = !expanded },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
capture.kind.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
capture.status,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = statusColor,
|
||||||
|
)
|
||||||
|
if (editable) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onRetry,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
|
Text("retry", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (editable && expanded) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showEdit = true },
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
|
Text("edit", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
capture.body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
||||||
|
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
capture.createdAt,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (capture.lastError != null) {
|
||||||
|
Text(
|
||||||
|
capture.lastError,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package me.hgsky.synq.ui.history
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.hgsky.synq.SynqApp
|
||||||
|
import me.hgsky.synq.data.db.CaptureEntity
|
||||||
|
|
||||||
|
class HistoryViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
private val dao = (app as SynqApp).db.captureDao()
|
||||||
|
|
||||||
|
val captures: Flow<List<CaptureEntity>> = dao.observeAll()
|
||||||
|
|
||||||
|
val lastSyncedAt: StateFlow<String?> = dao.getLastSyncedAt()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
|
fun retryCapture(capture: CaptureEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
dao.updateStatus(capture.id, "pending", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBody(capture: CaptureEntity, newBody: String) {
|
||||||
|
val trimmed = newBody.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
viewModelScope.launch { dao.updateBody(capture.id, trimmed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package me.hgsky.synq.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import me.hgsky.synq.data.SynqSettings
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
|
||||||
|
val saved by vm.settings.collectAsState()
|
||||||
|
val ping by vm.ping.collectAsState()
|
||||||
|
|
||||||
|
var url by remember(saved.serverUrl) { mutableStateOf(saved.serverUrl) }
|
||||||
|
var token by remember(saved.token) { mutableStateOf(saved.token) }
|
||||||
|
var device by remember(saved.deviceLabel) { mutableStateOf(saved.deviceLabel) }
|
||||||
|
var interval by remember(saved.syncIntervalMinutes) { mutableStateOf(saved.syncIntervalMinutes.toString()) }
|
||||||
|
|
||||||
|
fun buildSettings() = SynqSettings(
|
||||||
|
serverUrl = url.trim(),
|
||||||
|
token = token.trim(),
|
||||||
|
deviceLabel = device.trim().ifEmpty { "android" },
|
||||||
|
syncIntervalMinutes = interval.toIntOrNull()?.coerceAtLeast(15) ?: 15,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveAndBack() {
|
||||||
|
vm.save(buildSettings())
|
||||||
|
nav.navigateUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("settings") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = ::saveAndBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = url,
|
||||||
|
onValueChange = { url = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("server url") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = token,
|
||||||
|
onValueChange = { token = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("bearer token") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = device,
|
||||||
|
onValueChange = { device = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("device label") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = interval,
|
||||||
|
onValueChange = { v -> if (v.length <= 4 && v.all { it.isDigit() }) interval = v },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("sync interval (minutes, min 15)") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
supportingText = { Text("WorkManager minimum is 15 min") },
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
vm.save(buildSettings())
|
||||||
|
vm.checkConnection(url.trim())
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = ping !is PingState.Checking,
|
||||||
|
) {
|
||||||
|
if (ping is PingState.Checking) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Text("check connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = ::saveAndBack,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) { Text("save") }
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val p = ping) {
|
||||||
|
is PingState.Ok -> Text(
|
||||||
|
"✓ reachable",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color(0xFF2E7D32),
|
||||||
|
)
|
||||||
|
is PingState.Failed -> Text(
|
||||||
|
"✗ unreachable — check URL and that server is running",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package me.hgsky.synq.ui.settings
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.hgsky.synq.SynqApp
|
||||||
|
import me.hgsky.synq.data.SynqSettings
|
||||||
|
import me.hgsky.synq.data.api.SynqApiClient
|
||||||
|
import me.hgsky.synq.data.sync.SyncWorker
|
||||||
|
|
||||||
|
sealed class PingState {
|
||||||
|
object Idle : PingState()
|
||||||
|
object Checking : PingState()
|
||||||
|
data class Ok(val url: String) : PingState()
|
||||||
|
data class Failed(val url: String) : PingState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
private val synqApp = app as SynqApp
|
||||||
|
private val repo = synqApp.settings
|
||||||
|
|
||||||
|
val settings = repo.settings.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(5_000),
|
||||||
|
SynqSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _ping = MutableStateFlow<PingState>(PingState.Idle)
|
||||||
|
val ping: StateFlow<PingState> = _ping.asStateFlow()
|
||||||
|
|
||||||
|
fun save(settings: SynqSettings) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.save(settings)
|
||||||
|
SyncWorker.schedule(synqApp, settings.syncIntervalMinutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkConnection(url: String) {
|
||||||
|
if (_ping.value is PingState.Checking) return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_ping.value = PingState.Checking
|
||||||
|
val ok = SynqApiClient().checkHealth(url.trim())
|
||||||
|
_ping.value = if (ok) PingState.Ok(url) else PingState.Failed(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
android/app/src/main/kotlin/me/hgsky/synq/ui/theme/Theme.kt
Normal file
14
android/app/src/main/kotlin/me/hgsky/synq/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package me.hgsky.synq.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SynqTheme(content: @Composable () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val colorScheme = dynamicLightColorScheme(context)
|
||||||
|
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||||
|
}
|
||||||
3
android/app/src/main/res/values/strings.xml
Normal file
3
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">synq</string>
|
||||||
|
</resources>
|
||||||
3
android/app/src/main/res/values/themes.xml
Normal file
3
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.Synq" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||||
|
</resources>
|
||||||
7
android/build.gradle.kts
Normal file
7
android/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.13.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
|
||||||
|
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
|
||||||
|
}
|
||||||
5
android/gradle.properties
Normal file
5
android/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
org.gradle.daemon=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
17
android/settings.gradle.kts
Normal file
17
android/settings.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "synq"
|
||||||
|
include(":app")
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
synq-server:
|
||||||
|
build: ./server
|
||||||
|
container_name: synq-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8765:8765"
|
||||||
|
environment:
|
||||||
|
PHONE_CAPTURE_TOKEN: "${PHONE_CAPTURE_TOKEN}"
|
||||||
|
PHONE_CAPTURE_ORG_PATH: "/data/synq.org"
|
||||||
|
PHONE_CAPTURE_DB_PATH: "/data/capture.sqlite3"
|
||||||
|
PHONE_CAPTURE_HOST: "0.0.0.0"
|
||||||
|
PHONE_CAPTURE_PORT: "8765"
|
||||||
|
volumes:
|
||||||
|
- /mnt/user/ben/synq/phone-capture:/data
|
||||||
BIN
docs/history-m2.png
Normal file
BIN
docs/history-m2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/home-m2.png
Normal file
BIN
docs/home-m2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/settings-m2.png
Normal file
BIN
docs/settings-m2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
17
server/Dockerfile
Normal file
17
server/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN pip install --no-cache-dir fastapi "uvicorn[standard]" pydantic
|
||||||
|
|
||||||
|
COPY app/ app/
|
||||||
|
|
||||||
|
ENV PHONE_CAPTURE_HOST=0.0.0.0
|
||||||
|
ENV PHONE_CAPTURE_PORT=8765
|
||||||
|
ENV PHONE_CAPTURE_ORG_PATH=/data/synq.org
|
||||||
|
ENV PHONE_CAPTURE_DB_PATH=/data/capture.sqlite3
|
||||||
|
|
||||||
|
EXPOSE 8765
|
||||||
|
|
||||||
|
CMD ["python", "-m", "app.main"]
|
||||||
0
server/app/__init__.py
Normal file
0
server/app/__init__.py
Normal file
133
server/app/main.py
Normal file
133
server/app/main.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .models import CaptureRequest, CaptureResponse, HealthResponse
|
||||||
|
from .org_writer import format_capture
|
||||||
|
from .store import IdempotencyStore, get_file_lock
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("synq")
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
_load_token()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="synq", version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
_store: IdempotencyStore | None = None
|
||||||
|
_token_cache: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_token() -> str:
|
||||||
|
global _token_cache
|
||||||
|
if _token_cache:
|
||||||
|
return _token_cache
|
||||||
|
|
||||||
|
env_token = os.environ.get("PHONE_CAPTURE_TOKEN", "").strip()
|
||||||
|
if env_token and env_token != "change-me" and env_token != "change-me-long-random-token":
|
||||||
|
_token_cache = env_token
|
||||||
|
return _token_cache
|
||||||
|
|
||||||
|
token_file = Path(os.environ.get("PHONE_CAPTURE_DB_PATH", "/data/capture.sqlite3")).parent / "token.txt"
|
||||||
|
if token_file.exists():
|
||||||
|
stored = token_file.read_text().strip()
|
||||||
|
if stored:
|
||||||
|
_token_cache = stored
|
||||||
|
logger.info("synq token loaded from %s", token_file)
|
||||||
|
return _token_cache
|
||||||
|
|
||||||
|
generated = secrets.token_hex(32)
|
||||||
|
token_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
token_file.write_text(generated)
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(" SYNQ TOKEN (paste into app settings):")
|
||||||
|
logger.info(" %s", generated)
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("")
|
||||||
|
_token_cache = generated
|
||||||
|
return _token_cache
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_store() -> IdempotencyStore:
|
||||||
|
global _store
|
||||||
|
if _store is None:
|
||||||
|
db_path = os.environ.get("PHONE_CAPTURE_DB_PATH", "/data/capture.sqlite3")
|
||||||
|
_store = IdempotencyStore(db_path)
|
||||||
|
return _store
|
||||||
|
|
||||||
|
|
||||||
|
def _org_path() -> str:
|
||||||
|
return os.environ.get("PHONE_CAPTURE_ORG_PATH", "/data/synq.org")
|
||||||
|
|
||||||
|
|
||||||
|
def _token() -> str:
|
||||||
|
return _load_token()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_token(request: Request) -> None:
|
||||||
|
expected = _token()
|
||||||
|
if not expected:
|
||||||
|
raise HTTPException(status_code=500, detail="server token not configured")
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
scheme, _, provided = auth.partition(" ")
|
||||||
|
if scheme.lower() != "bearer" or provided != expected:
|
||||||
|
logger.warning("rejected request: invalid token from %s", request.client.host if request.client else "unknown")
|
||||||
|
raise HTTPException(status_code=401, detail="unauthorized")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
async def health() -> HealthResponse:
|
||||||
|
return HealthResponse(ok=True, service="synq", version="0.1.0")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/capture", response_model=CaptureResponse, dependencies=[Depends(check_token)])
|
||||||
|
async def capture(payload: CaptureRequest, store: IdempotencyStore = Depends(get_store)) -> CaptureResponse:
|
||||||
|
if store.already_seen(payload.id):
|
||||||
|
logger.info("duplicate capture id=%s", payload.id)
|
||||||
|
return CaptureResponse(ok=True, status="already_seen", id=payload.id)
|
||||||
|
|
||||||
|
entry = format_capture(payload)
|
||||||
|
org_path = _org_path()
|
||||||
|
|
||||||
|
lock = get_file_lock()
|
||||||
|
with lock:
|
||||||
|
org_file = Path(org_path)
|
||||||
|
org_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not org_file.exists():
|
||||||
|
org_file.write_text("#+title: synq captures\n#+startup: overview\n\n", encoding="utf-8")
|
||||||
|
with open(org_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(entry)
|
||||||
|
store.mark_seen(payload.id, payload.created_at)
|
||||||
|
|
||||||
|
logger.info("accepted capture id=%s", payload.id)
|
||||||
|
return CaptureResponse(ok=True, status="accepted", id=payload.id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
|
errors = exc.errors()
|
||||||
|
fields = [str(e.get("loc", "")) for e in errors]
|
||||||
|
logger.warning("rejected payload: validation failed fields=%s from=%s", fields, request.client.host if request.client else "unknown")
|
||||||
|
for err in errors:
|
||||||
|
if "body" in err.get("loc", ()):
|
||||||
|
return JSONResponse(status_code=400, content={"detail": "body must not be empty"})
|
||||||
|
msg = str(errors[0]["msg"]) if errors else "invalid payload"
|
||||||
|
return JSONResponse(status_code=400, content={"detail": msg})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
host = os.environ.get("PHONE_CAPTURE_HOST", "0.0.0.0")
|
||||||
|
port = int(os.environ.get("PHONE_CAPTURE_PORT", "8765"))
|
||||||
|
uvicorn.run("app.main:app", host=host, port=port, reload=False)
|
||||||
45
server/app/models.py
Normal file
45
server/app/models.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureRequest(BaseModel):
|
||||||
|
id: str
|
||||||
|
created_at: str
|
||||||
|
kind: Literal["note", "todo"]
|
||||||
|
body: str
|
||||||
|
tags: list[str]
|
||||||
|
device: str
|
||||||
|
|
||||||
|
@field_validator("body")
|
||||||
|
@classmethod
|
||||||
|
def body_must_not_be_empty(cls, v: str) -> str:
|
||||||
|
stripped = v.strip()
|
||||||
|
if not stripped:
|
||||||
|
raise ValueError("body must not be empty")
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
@field_validator("id")
|
||||||
|
@classmethod
|
||||||
|
def id_must_not_be_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("id must not be empty")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("device")
|
||||||
|
@classmethod
|
||||||
|
def device_must_not_be_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("device must not be empty")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
status: str
|
||||||
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
service: str
|
||||||
|
version: str
|
||||||
68
server/app/org_writer.py
Normal file
68
server/app/org_writer.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from .models import CaptureRequest
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tags(tags: Sequence[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for raw in tags:
|
||||||
|
tag = raw.strip().lower()
|
||||||
|
tag = tag.lstrip("#")
|
||||||
|
tag = tag.replace(" ", "_")
|
||||||
|
tag = re.sub(r"[^a-z0-9_]", "", tag)
|
||||||
|
if tag and tag not in seen:
|
||||||
|
seen.add(tag)
|
||||||
|
result.append(tag)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _org_tags(tags: list[str]) -> str:
|
||||||
|
if not tags:
|
||||||
|
return ""
|
||||||
|
return " :" + ":".join(tags) + ":"
|
||||||
|
|
||||||
|
|
||||||
|
def _created_stamp(created_at: str) -> str:
|
||||||
|
"""Parse ISO-8601 datetime and format as org inactive timestamp."""
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(created_at)
|
||||||
|
except ValueError:
|
||||||
|
dt = datetime.now(timezone.utc)
|
||||||
|
day_abbr = dt.strftime("%a").lower()
|
||||||
|
return f"[{dt.strftime('%Y-%m-%d')} {day_abbr} {dt.strftime('%H:%M')}]"
|
||||||
|
|
||||||
|
|
||||||
|
def _property_drawer(created_stamp: str, source: str, capture_id: str) -> str:
|
||||||
|
return (
|
||||||
|
":PROPERTIES:\n"
|
||||||
|
f":CREATED: {created_stamp}\n"
|
||||||
|
f":SOURCE: {source}\n"
|
||||||
|
f":ID: {capture_id}\n"
|
||||||
|
":END:"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_capture(capture: CaptureRequest) -> str:
|
||||||
|
tags = normalize_tags(capture.tags)
|
||||||
|
tag_str = _org_tags(tags)
|
||||||
|
created_stamp = _created_stamp(capture.created_at)
|
||||||
|
drawer = _property_drawer(created_stamp, capture.device, capture.id)
|
||||||
|
|
||||||
|
if capture.kind == "todo":
|
||||||
|
heading = f"* TODO {capture.body}{tag_str}"
|
||||||
|
return f"{heading}\n{drawer}\n"
|
||||||
|
|
||||||
|
# note
|
||||||
|
lines = capture.body.split("\n")
|
||||||
|
first_line = lines[0].rstrip()
|
||||||
|
is_multiline = len(lines) > 1 and any(l.strip() for l in lines[1:])
|
||||||
|
|
||||||
|
if is_multiline:
|
||||||
|
heading = f"* note: {first_line}{tag_str}"
|
||||||
|
return f"{heading}\n{drawer}\n\n{capture.body}\n"
|
||||||
|
else:
|
||||||
|
heading = f"* note{tag_str}"
|
||||||
|
return f"{heading}\n{drawer}\n\n{capture.body}\n"
|
||||||
38
server/app/store.py
Normal file
38
server/app/store.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(db_path: str) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS seen_ids (id TEXT PRIMARY KEY, created_at TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyStore:
|
||||||
|
def __init__(self, db_path: str) -> None:
|
||||||
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._conn = _connect(db_path)
|
||||||
|
|
||||||
|
def already_seen(self, capture_id: str) -> bool:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT 1 FROM seen_ids WHERE id = ?", (capture_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def mark_seen(self, capture_id: str, created_at: str) -> None:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO seen_ids (id, created_at) VALUES (?, ?)",
|
||||||
|
(capture_id, created_at),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_lock() -> threading.Lock:
|
||||||
|
return _lock
|
||||||
22
server/pyproject.toml
Normal file
22
server/pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[project]
|
||||||
|
name = "synq-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.111",
|
||||||
|
"uvicorn[standard]>=0.29",
|
||||||
|
"pydantic>=2.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
0
server/tests/__init__.py
Normal file
0
server/tests/__init__.py
Normal file
118
server/tests/test_api.py
Normal file
118
server/tests/test_api.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
# Set env vars before importing app so store/paths are overridden in tests
|
||||||
|
TOKEN = "test-token-abc"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def env_setup(tmp_path, monkeypatch):
|
||||||
|
db = str(tmp_path / "capture.sqlite3")
|
||||||
|
org = str(tmp_path / "synq.org")
|
||||||
|
monkeypatch.setenv("PHONE_CAPTURE_TOKEN", TOKEN)
|
||||||
|
monkeypatch.setenv("PHONE_CAPTURE_DB_PATH", db)
|
||||||
|
monkeypatch.setenv("PHONE_CAPTURE_ORG_PATH", org)
|
||||||
|
# reset singleton store so each test gets a fresh db
|
||||||
|
import app.main as m
|
||||||
|
m._store = None
|
||||||
|
yield
|
||||||
|
m._store = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers():
|
||||||
|
return {"Authorization": f"Bearer {TOKEN}"}
|
||||||
|
|
||||||
|
|
||||||
|
VALID_PAYLOAD = {
|
||||||
|
"id": "phone-20260517-143122-a8f2",
|
||||||
|
"created_at": "2026-05-17T14:31:22-04:00",
|
||||||
|
"kind": "todo",
|
||||||
|
"body": "buy printer paper",
|
||||||
|
"tags": ["home", "errands"],
|
||||||
|
"device": "android",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealth:
|
||||||
|
def test_health_returns_200(self, client):
|
||||||
|
r = client.get("/health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["service"] == "synq"
|
||||||
|
assert data["version"] == "0.1.0"
|
||||||
|
|
||||||
|
def test_health_no_auth_required(self, client):
|
||||||
|
r = client.get("/health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapture:
|
||||||
|
def test_valid_capture_accepted(self, client, tmp_path):
|
||||||
|
r = client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["status"] == "accepted"
|
||||||
|
assert data["id"] == VALID_PAYLOAD["id"]
|
||||||
|
|
||||||
|
def test_valid_capture_appended_to_org(self, client, monkeypatch, tmp_path):
|
||||||
|
org = str(tmp_path / "synq.org")
|
||||||
|
monkeypatch.setenv("PHONE_CAPTURE_ORG_PATH", org)
|
||||||
|
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
|
||||||
|
content = open(org, encoding="utf-8").read()
|
||||||
|
assert "buy printer paper" in content
|
||||||
|
assert ":ID: phone-20260517-143122-a8f2" in content
|
||||||
|
|
||||||
|
def test_duplicate_capture_returns_already_seen(self, client):
|
||||||
|
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
|
||||||
|
r = client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "already_seen"
|
||||||
|
|
||||||
|
def test_duplicate_not_appended_twice(self, client, monkeypatch, tmp_path):
|
||||||
|
org = str(tmp_path / "synq.org")
|
||||||
|
monkeypatch.setenv("PHONE_CAPTURE_ORG_PATH", org)
|
||||||
|
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
|
||||||
|
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
|
||||||
|
content = open(org, encoding="utf-8").read()
|
||||||
|
assert content.count(VALID_PAYLOAD["id"]) == 1
|
||||||
|
|
||||||
|
def test_missing_token_rejected(self, client):
|
||||||
|
r = client.post("/capture", json=VALID_PAYLOAD)
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.json()["detail"] == "unauthorized"
|
||||||
|
|
||||||
|
def test_wrong_token_rejected(self, client):
|
||||||
|
r = client.post("/capture", json=VALID_PAYLOAD, headers={"Authorization": "Bearer wrong"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
def test_empty_body_rejected(self, client):
|
||||||
|
payload = {**VALID_PAYLOAD, "body": " "}
|
||||||
|
r = client.post("/capture", json=payload, headers=auth_headers())
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "body" in r.json()["detail"].lower()
|
||||||
|
|
||||||
|
def test_missing_body_field_rejected(self, client):
|
||||||
|
payload = {k: v for k, v in VALID_PAYLOAD.items() if k != "body"}
|
||||||
|
r = client.post("/capture", json=payload, headers=auth_headers())
|
||||||
|
assert r.status_code in (400, 422)
|
||||||
|
|
||||||
|
def test_invalid_kind_rejected(self, client):
|
||||||
|
payload = {**VALID_PAYLOAD, "kind": "journal"}
|
||||||
|
r = client.post("/capture", json=payload, headers=auth_headers())
|
||||||
|
assert r.status_code in (400, 422)
|
||||||
|
|
||||||
|
def test_note_kind_accepted(self, client):
|
||||||
|
payload = {**VALID_PAYLOAD, "id": "phone-20260517-143200-note1", "kind": "note"}
|
||||||
|
r = client.post("/capture", json=payload, headers=auth_headers())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "accepted"
|
||||||
106
server/tests/test_org_writer.py
Normal file
106
server/tests/test_org_writer.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import pytest
|
||||||
|
from app.models import CaptureRequest
|
||||||
|
from app.org_writer import format_capture, normalize_tags
|
||||||
|
|
||||||
|
|
||||||
|
def make_capture(**kwargs) -> CaptureRequest:
|
||||||
|
defaults = dict(
|
||||||
|
id="phone-20260517-143122-a8f2",
|
||||||
|
created_at="2026-05-17T14:31:22-04:00",
|
||||||
|
kind="note",
|
||||||
|
body="hello world",
|
||||||
|
tags=[],
|
||||||
|
device="android",
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return CaptureRequest(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeTags:
|
||||||
|
def test_lowercase(self):
|
||||||
|
assert normalize_tags(["Home", "ERRANDS"]) == ["home", "errands"]
|
||||||
|
|
||||||
|
def test_strips_hash(self):
|
||||||
|
assert normalize_tags(["#home"]) == ["home"]
|
||||||
|
|
||||||
|
def test_replaces_spaces(self):
|
||||||
|
assert normalize_tags(["my tag"]) == ["my_tag"]
|
||||||
|
|
||||||
|
def test_removes_invalid_chars(self):
|
||||||
|
assert normalize_tags(["tag!"]) == ["tag"]
|
||||||
|
|
||||||
|
def test_deduplicates(self):
|
||||||
|
assert normalize_tags(["home", "home"]) == ["home"]
|
||||||
|
|
||||||
|
def test_omits_empty(self):
|
||||||
|
assert normalize_tags(["", " "]) == []
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
assert normalize_tags([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatCapture:
|
||||||
|
def test_todo_with_tags(self):
|
||||||
|
c = make_capture(kind="todo", body="buy printer paper", tags=["home", "errands"])
|
||||||
|
result = format_capture(c)
|
||||||
|
assert result.startswith("* TODO buy printer paper :home:errands:\n")
|
||||||
|
assert ":CREATED:" in result
|
||||||
|
assert ":SOURCE: android" in result
|
||||||
|
assert ":ID: phone-20260517-143122-a8f2" in result
|
||||||
|
|
||||||
|
def test_todo_no_tags(self):
|
||||||
|
c = make_capture(kind="todo", body="do the thing", tags=[])
|
||||||
|
result = format_capture(c)
|
||||||
|
assert result.startswith("* TODO do the thing\n")
|
||||||
|
|
||||||
|
def test_note_single_line(self):
|
||||||
|
c = make_capture(kind="note", body="mobile capture should stay dumb and append-only.", tags=["retcon"])
|
||||||
|
result = format_capture(c)
|
||||||
|
assert result.startswith("* note :retcon:\n")
|
||||||
|
assert "mobile capture should stay dumb and append-only." in result
|
||||||
|
assert ":PROPERTIES:" in result
|
||||||
|
|
||||||
|
def test_note_no_tags(self):
|
||||||
|
c = make_capture(kind="note", body="simple note", tags=[])
|
||||||
|
result = format_capture(c)
|
||||||
|
assert result.startswith("* note\n")
|
||||||
|
assert "simple note" in result
|
||||||
|
|
||||||
|
def test_note_multiline(self):
|
||||||
|
body = "retcon capture idea\n\nphone should produce records, not edit org files."
|
||||||
|
c = make_capture(kind="note", body=body, tags=["retcon"])
|
||||||
|
result = format_capture(c)
|
||||||
|
assert result.startswith("* note: retcon capture idea :retcon:\n")
|
||||||
|
assert "retcon capture idea" in result
|
||||||
|
assert "phone should produce records, not edit org files." in result
|
||||||
|
assert ":PROPERTIES:" in result
|
||||||
|
|
||||||
|
def test_created_timestamp_format(self):
|
||||||
|
c = make_capture(created_at="2026-05-17T14:31:22-04:00")
|
||||||
|
result = format_capture(c)
|
||||||
|
assert ":CREATED: [2026-05-17 sun 14:31]" in result
|
||||||
|
|
||||||
|
def test_property_drawer_complete(self):
|
||||||
|
c = make_capture()
|
||||||
|
result = format_capture(c)
|
||||||
|
assert ":PROPERTIES:" in result
|
||||||
|
assert ":END:" in result
|
||||||
|
|
||||||
|
def test_todo_body_below_drawer_absent(self):
|
||||||
|
c = make_capture(kind="todo", body="simple todo")
|
||||||
|
result = format_capture(c)
|
||||||
|
lines = result.strip().split("\n")
|
||||||
|
# heading, drawer block — no extra body section
|
||||||
|
assert lines[0].startswith("* TODO")
|
||||||
|
assert ":END:" in result
|
||||||
|
# body should not appear after :END:
|
||||||
|
end_idx = result.index(":END:")
|
||||||
|
after_end = result[end_idx + len(":END:"):].strip()
|
||||||
|
assert after_end == ""
|
||||||
|
|
||||||
|
def test_note_body_appears_after_drawer(self):
|
||||||
|
c = make_capture(kind="note", body="my note body")
|
||||||
|
result = format_capture(c)
|
||||||
|
end_idx = result.index(":END:")
|
||||||
|
after_end = result[end_idx + len(":END:"):].strip()
|
||||||
|
assert "my note body" in after_end
|
||||||
328
tasks.org
328
tasks.org
@@ -39,17 +39,20 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
|
|||||||
- service has tests for org formatting and duplicate handling.
|
- service has tests for org formatting and duplicate handling.
|
||||||
|
|
||||||
* milestone 0: repo setup
|
* milestone 0: repo setup
|
||||||
** TODO create monorepo skeleton
|
** DONE create monorepo skeleton
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- `android/`, `server/`, and `docs/` directories exist.
|
- `android/`, `server/`, and `docs/` directories exist.
|
||||||
- root `README.md`, `tasks.org`, `.env.example`, and `docker-compose.example.yml` exist.
|
- root `README.md`, `tasks.org`, `.env.example`, and `docker-compose.example.yml` exist.
|
||||||
*** notes
|
|
||||||
*** evidence
|
|
||||||
- commit:
|
|
||||||
- tests:
|
|
||||||
- datetime:
|
|
||||||
|
|
||||||
** TODO add root gitignore
|
*** notes
|
||||||
|
Skeleton committed in initial commit. android/ dir will be created in milestone 2.
|
||||||
|
|
||||||
|
*** evidence
|
||||||
|
- commit: c08b3fe
|
||||||
|
- tests: n/a (directory structure)
|
||||||
|
- datetime: [2026-05-17 Sat 00:00]
|
||||||
|
|
||||||
|
** DONE add root gitignore
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- ignores android build outputs.
|
- ignores android build outputs.
|
||||||
- ignores python virtualenv/cache.
|
- ignores python virtualenv/cache.
|
||||||
@@ -57,45 +60,66 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
|
|||||||
- ignores local env files.
|
- ignores local env files.
|
||||||
- does not ignore docs or source files.
|
- does not ignore docs or source files.
|
||||||
*** notes
|
*** notes
|
||||||
|
Standard gitignore covering Python venv/cache, sqlite db files, .env, and Android build outputs.
|
||||||
*** evidence
|
*** evidence
|
||||||
- commit:
|
- commit: c08b3fe
|
||||||
- tests:
|
- tests: n/a
|
||||||
- datetime:
|
- datetime: [2026-05-17 Sat 00:00]
|
||||||
** TODO document v1 scope in README
|
|
||||||
|
** DONE document v1 scope in README
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- states capture-only scope.
|
- states capture-only scope.
|
||||||
- states non-goals.
|
- states non-goals.
|
||||||
- states architecture and build order.
|
- states architecture and build order.
|
||||||
*** notes
|
*** notes
|
||||||
|
README covers non-goals, architecture diagram, v1 behavior, org output format, and build order.
|
||||||
*** evidence
|
*** evidence
|
||||||
- commit:
|
- commit: c08b3fe
|
||||||
- tests:
|
- tests: n/a
|
||||||
- datetime:
|
- datetime: [2026-05-17 Sat 00:00]
|
||||||
** TODO add task template to tasks.org
|
|
||||||
|
** DONE add task template to tasks.org
|
||||||
*** acceptance:
|
*** acceptance:
|
||||||
- `*** notes` section for each milestone task, to be used by you to document your decisions and hurdles overcome
|
- `*** notes` section for each milestone task, to be used by you to document your decisions and hurdles overcome
|
||||||
- `*** evidence` section with sub bullets:
|
- `*** evidence` section with sub bullets:
|
||||||
- `commit` list of commit hashes on this task
|
- `commit` list of commit hashes on this task
|
||||||
- `tests` describing tests run (anyone should be able to use these later)
|
- `tests` describing tests run (anyone should be able to use these later)
|
||||||
- `datetime` the task was completed, including timestamp, eg [2026-05-17 Sun 16:06]
|
- `datetime` the task was completed, including timestamp, eg [2026-05-17 Sun 16:06]
|
||||||
*** notes
|
*** notes
|
||||||
|
Template added in 5f387df. All milestone tasks now have notes/evidence blocks.
|
||||||
*** evidence
|
*** evidence
|
||||||
- commit:
|
- commit: 5f387df
|
||||||
- tests:
|
- tests: n/a
|
||||||
- datetime:
|
- datetime: [2026-05-17 Sat 00:00]
|
||||||
* milestone 1: server mvp
|
* milestone 1: server mvp
|
||||||
** TODO create fastapi app
|
** DONE create fastapi app
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- `GET /health` returns 200 with simple json.
|
- `GET /health` returns 200 with simple json.
|
||||||
- `POST /capture` accepts a valid capture payload.
|
- `POST /capture` accepts a valid capture payload.
|
||||||
- auth token is required for `POST /capture`.
|
- auth token is required for `POST /capture`.
|
||||||
** TODO define pydantic capture model
|
*** notes
|
||||||
|
FastAPI app in server/app/main.py. Auth via Bearer token dependency injected on POST /capture. Health endpoint needs no auth.
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00
|
||||||
|
- tests: pytest tests/test_api.py::TestHealth, tests/test_api.py::TestCapture — 12 tests, all pass
|
||||||
|
- datetime: [2026-05-17 Sat 17:00]
|
||||||
|
|
||||||
|
** DONE define pydantic capture model
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- requires id, created_at, kind, body, tags, and device.
|
- requires id, created_at, kind, body, tags, and device.
|
||||||
- kind is constrained to note/todo.
|
- kind is constrained to note/todo.
|
||||||
- body is trimmed and must not be empty.
|
- body is trimmed and must not be empty.
|
||||||
- tags are normalized before formatting.
|
- tags are normalized before formatting.
|
||||||
** TODO implement org formatter
|
|
||||||
|
*** notes
|
||||||
|
Pydantic v2 model in server/app/models.py. Body is stripped via field_validator; kind uses Literal["note","todo"]. Tag normalization lives in org_writer.py so the model stays a plain schema.
|
||||||
|
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00
|
||||||
|
- tests: pytest tests/test_api.py::TestCapture::test_empty_body_rejected, test_invalid_kind_rejected — pass
|
||||||
|
- datetime: [2026-05-17 Sat 17:00]
|
||||||
|
|
||||||
|
** DONE implement org formatter
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- todo captures produce `* TODO ...`.
|
- todo captures produce `* TODO ...`.
|
||||||
- note captures produce `* note ...`.
|
- note captures produce `* note ...`.
|
||||||
@@ -103,40 +127,94 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
|
|||||||
- tags are emitted as org heading tags.
|
- tags are emitted as org heading tags.
|
||||||
- multiline note body is preserved.
|
- multiline note body is preserved.
|
||||||
- tests cover todo, note, tags, empty tags, and multiline body.
|
- tests cover todo, note, tags, empty tags, and multiline body.
|
||||||
** TODO implement sqlite idempotency store
|
|
||||||
|
*** notes
|
||||||
|
Pure function format_capture() in server/app/org_writer.py. Single-line notes: heading is "* note", body below drawer. Multiline notes: heading is "* note: <first line>", full body below drawer. Tag normalization strips #, lowercases, replaces spaces with _, removes non-[a-z0-9_] chars, deduplicates.
|
||||||
|
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00
|
||||||
|
- tests: pytest tests/test_org_writer.py — 15 tests (7 normalize_tags + 8 format_capture), all pass
|
||||||
|
- datetime: [2026-05-17 Sat 17:00]
|
||||||
|
|
||||||
|
** DONE implement sqlite idempotency store
|
||||||
|
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- accepted capture ids are stored.
|
- accepted capture ids are stored.
|
||||||
- repeated id returns accepted/already-seen without appending.
|
- repeated id returns accepted/already-seen without appending.
|
||||||
- db path is configurable.
|
- db path is configurable.
|
||||||
** TODO implement append writer
|
|
||||||
|
*** notes
|
||||||
|
IdempotencyStore in server/app/store.py. Uses sqlite3 with a seen_ids table. DB path from PHONE_CAPTURE_DB_PATH env var. already_seen() checks before write; mark_seen() inserts after append.
|
||||||
|
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00
|
||||||
|
- tests: pytest tests/test_api.py::TestCapture::test_duplicate_capture_returns_already_seen, test_duplicate_not_appended_twice — pass
|
||||||
|
- datetime: [2026-05-17 Sat 17:00]
|
||||||
|
|
||||||
|
** DONE implement append writer
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- appends to configured org path.
|
- appends to configured org path.
|
||||||
- creates file if missing.
|
- creates file if missing.
|
||||||
- writes utf-8.
|
- writes utf-8.
|
||||||
- appends exactly one entry per new capture.
|
- appends exactly one entry per new capture.
|
||||||
- uses file lock or equivalent simple concurrency guard.
|
- uses file lock or equivalent simple concurrency guard.
|
||||||
** TODO add server tests
|
|
||||||
|
*** notes
|
||||||
|
Append logic in main.py POST /capture handler. Uses a module-level threading.Lock (get_file_lock()) around open(path, "a", encoding="utf-8"). Creates parent dirs if missing. Org path from PHONE_CAPTURE_ORG_PATH env var.
|
||||||
|
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00
|
||||||
|
- tests: pytest tests/test_api.py::TestCapture::test_valid_capture_appended_to_org, test_duplicate_not_appended_twice — pass
|
||||||
|
- datetime: [2026-05-17 Sat 17:00]
|
||||||
|
|
||||||
|
** DONE add server tests
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- health endpoint test.
|
- health endpoint test.
|
||||||
- valid capture append test.
|
- valid capture append test.
|
||||||
- duplicate capture test.
|
- duplicate capture test.
|
||||||
- invalid token test.
|
- invalid token test.
|
||||||
- empty body rejection test.
|
- empty body rejection test.
|
||||||
** TODO containerize server
|
|
||||||
|
*** notes
|
||||||
|
28 tests total across test_api.py (12) and test_org_writer.py (16). All pass. Run with: cd server && python -m pytest tests/ -v
|
||||||
|
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00
|
||||||
|
- tests: pytest tests/ — 28 passed in 0.33s
|
||||||
|
- datetime: [2026-05-17 Sat 17:00]
|
||||||
|
|
||||||
|
** DONE containerize server
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- dockerfile builds server image.
|
- dockerfile builds server image.
|
||||||
- compose example maps `/data`.
|
- compose example maps `/data`.
|
||||||
- env vars configure token, org file, and sqlite path.
|
- env vars configure token, org file, and sqlite path.
|
||||||
- container starts and exposes configured port.
|
- container starts and exposes configured port.
|
||||||
|
|
||||||
|
*** notes
|
||||||
|
server/Dockerfile uses python:3.11-slim, installs deps, copies app/, exposes 8765. docker-compose.example.yml already existed in root with correct env var mapping. CMD runs python -m app.main via uvicorn.
|
||||||
|
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00
|
||||||
|
- tests: n/a (docker build not run in CI; validate manually with: docker build ./server && docker run -e PHONE_CAPTURE_TOKEN=... -p 8765:8765 -v /data:/data <image>)
|
||||||
|
- datetime: [2026-05-17 Sat 17:00]
|
||||||
|
|
||||||
* milestone 2: android local capture
|
* milestone 2: android local capture
|
||||||
** TODO create android project
|
** DONE create android project
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- kotlin android app builds.
|
- kotlin android app builds.
|
||||||
- jetpack compose enabled.
|
- jetpack compose enabled.
|
||||||
- min sdk is reasonable for current personal device.
|
- min sdk is reasonable for current personal device.
|
||||||
- app id is stable, e.g. `me.hgsky.phonecapture`.
|
- app id is stable, e.g. `me.hgsky.phonecapture`.
|
||||||
** TODO build capture screen
|
|
||||||
|
*** notes
|
||||||
|
Gradle project in android/. AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01. minSdk=31, compileSdk=36. App ID is me.hgsky.synq. Open android/ in Android Studio and hit Sync — AS will download the Gradle wrapper (8.9) automatically.
|
||||||
|
|
||||||
|
*** evidence
|
||||||
|
- commit: 19b05a8
|
||||||
|
- tests: Gradle sync in Android Studio (manual step)
|
||||||
|
- datetime: [2026-05-18 Sun 17:00]
|
||||||
|
|
||||||
|
** DONE build capture screen
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- app opens to multiline text box.
|
- app opens to multiline text box.
|
||||||
- keyboard opens automatically.
|
- keyboard opens automatically.
|
||||||
@@ -144,92 +222,254 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
|
|||||||
- tags field exists.
|
- tags field exists.
|
||||||
- save button exists.
|
- save button exists.
|
||||||
- save and close affordance exists.
|
- save and close affordance exists.
|
||||||
** TODO implement local room database
|
*** notes
|
||||||
|
CaptureScreen.kt uses LaunchedEffect(Unit) { focusRequester.requestFocus() } for auto-focus. note/todo Switch, tags OutlinedTextField, "save" OutlinedButton (stays), "save & close" Button (finishes activity). No network call on launch.
|
||||||
|
*** evidence
|
||||||
|
- commit: 19b05a8
|
||||||
|
- tests: manual — build and run on device/emulator
|
||||||
|
- datetime: [2026-05-18 Sun 17:00]
|
||||||
|
|
||||||
|
** DONE implement local room database
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- capture entity has id, created_at, kind, body, tags, device, status, synced_at, and last_error.
|
- capture entity has id, created_at, kind, body, tags, device, status, synced_at, and last_error.
|
||||||
- saving creates pending row.
|
- saving creates pending row.
|
||||||
- pending captures survive app restart.
|
- pending captures survive app restart.
|
||||||
** TODO implement capture id generation
|
*** notes
|
||||||
|
CaptureEntity, CaptureDao, CaptureDatabase in data/db/. Tags stored as JSON string (kotlinx-serialization). DB name synq.db, singleton via companion object. Room persists across restarts by default.
|
||||||
|
|
||||||
|
*** evidence
|
||||||
|
- commit: 19b05a8
|
||||||
|
- tests: manual — save a capture, force-close app, reopen and check history
|
||||||
|
- datetime: [2026-05-18 Sun 17:00]
|
||||||
|
|
||||||
|
** DONE implement capture id generation
|
||||||
|
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- id format is stable and readable, e.g. `phone-yyyymmdd-hhmmss-rand`.
|
- id format is stable and readable, e.g. `phone-yyyymmdd-hhmmss-rand`.
|
||||||
- ids are generated client-side.
|
- ids are generated client-side.
|
||||||
- tests or simple assertions prevent blank/duplicate ids in normal flow.
|
- tests or simple assertions prevent blank/duplicate ids in normal flow.
|
||||||
** TODO implement basic history screen
|
*** notes
|
||||||
|
generateCaptureId() in data/CaptureId.kt. Format: phone-YYYYMMDD-HHmmss-xxxx (4 random [a-z0-9] chars). Called in CaptureViewModel.save() before Room insert.
|
||||||
|
*** evidence
|
||||||
|
- commit: 19b05a8
|
||||||
|
- tests: manual — check id column in history; each save produces a unique phone-… id
|
||||||
|
- datetime: [2026-05-18 Sun 17:00]
|
||||||
|
|
||||||
|
** DONE implement basic history screen
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- lists recent captures.
|
- lists recent captures.
|
||||||
- shows pending/synced/failed status.
|
- shows pending/synced/failed status.
|
||||||
- failed row shows last error.
|
- failed row shows last error.
|
||||||
- user can retry failed/pending sync.
|
- user can retry failed/pending sync.
|
||||||
|
*** notes
|
||||||
|
HistoryScreen.kt observes captureDao.observeAll() as Flow. Status colored green/red/grey. Retry button shown for pending/failed; calls updateStatus(id, "pending", null) so the sync worker picks it up in milestone 3.
|
||||||
|
*** evidence
|
||||||
|
- commit: 19b05a8
|
||||||
|
- tests: manual — save captures, navigate to history, verify status labels and retry button
|
||||||
|
- datetime: [2026-05-18 Sun 17:00]
|
||||||
|
|
||||||
|
** DONE Checkout tests
|
||||||
|
[2026-05-18 Mon 12:40]
|
||||||
|
- updated Android Studio, ran Upgrade Assistant for Android Gradle Plugin to 8.13.2
|
||||||
|
- added Pixel 8 emulator
|
||||||
|
- ran a few fixes:
|
||||||
|
- `779ad6` - upped app memory to 2GB from 500MB - investigate?
|
||||||
|
- `7671416` - app.settings needs the SynqApp cast — same as dao. And it's unused in milestone 2 anyway, so just removed it
|
||||||
|
|
||||||
* milestone 3: android sync
|
* milestone 3: android sync
|
||||||
** TODO implement api client
|
** DONE implement api client
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- base url configurable in app settings or build config.
|
- base url configurable in app settings or build config.
|
||||||
- bearer token configurable in app settings or build config.
|
- bearer token configurable in app settings or build config.
|
||||||
- client can call `/health`.
|
- client can call `/health`.
|
||||||
- client can post capture payload.
|
- client can post capture payload.
|
||||||
** TODO implement manual sync
|
*** notes
|
||||||
|
SynqApiClient in data/api/. OkHttp with 10s timeouts. checkHealth() does GET /health, returns false on any exception so callers never crash. postCapture() maps 401 → failed, non-2xx → failed, already_seen → AlreadySeen, else → Accepted. Uses org.json for request building, kotlinx-serialization for tag decoding.
|
||||||
|
*** evidence
|
||||||
|
- commit: 2a963d9
|
||||||
|
- tests: manual — sync now button, check history screen status
|
||||||
|
- datetime: [2026-05-18 Sun 18:00]
|
||||||
|
|
||||||
|
** DONE implement manual sync
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- sync now posts all pending captures.
|
- sync now posts all pending captures.
|
||||||
- successful posts mark rows synced.
|
- successful posts mark rows synced.
|
||||||
- already-seen response marks row synced.
|
- already-seen response marks row synced.
|
||||||
- failures retain pending/failed status and last error.
|
- failures retain pending/failed status and last error.
|
||||||
** TODO implement opportunistic workmanager sync
|
*** notes
|
||||||
|
syncPending() top-level function in data/sync/SyncService.kt — shared by manual sync and WorkManager. CaptureViewModel.syncNow() calls it on Dispatchers.IO, guards with isSyncing flag. Sync icon in top bar shows CircularProgressIndicator while running.
|
||||||
|
*** evidence
|
||||||
|
- commit: 2a963d9
|
||||||
|
- tests: manual — tap sync, verify captures move to synced in history; test with wrong token, verify failed + error message
|
||||||
|
- datetime: [2026-05-18 Sun 18:00]
|
||||||
|
|
||||||
|
** DONE implement opportunistic workmanager sync
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- periodic sync is registered.
|
- periodic sync is registered.
|
||||||
- sync only runs when network is available.
|
- sync only runs when network is available.
|
||||||
- worker first checks `/health`.
|
- worker first checks `/health`.
|
||||||
- worker does not block capture speed.
|
- worker does not block capture speed.
|
||||||
** TODO add simple server reachability settings
|
*** notes
|
||||||
|
SyncWorker (CoroutineWorker) in data/sync/. Registered in SynqApp.onCreate() as KEEP unique periodic work (15-min interval, NETWORK_CONNECTED constraint). Health check before sync — exits quietly if server unreachable. Does not touch UI thread.
|
||||||
|
*** evidence
|
||||||
|
- commit: 2a963d9
|
||||||
|
- tests: manual — let app sit on WiFi, verify pending captures eventually sync without manual trigger
|
||||||
|
- datetime: [2026-05-18 Sun 18:00]
|
||||||
|
|
||||||
|
** DONE add simple server reachability settings
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- default url can be set to `http://jeeves.mother:8765`.
|
- default url can be set to `http://jeeves.mother:8765`.
|
||||||
- user can change server url.
|
- user can change server url.
|
||||||
- user can change token.
|
- user can change token.
|
||||||
- invalid settings do not crash app.
|
- invalid settings do not crash app.
|
||||||
|
*** notes
|
||||||
|
SettingsScreen + SettingsViewModel from milestone 2. DataStore-backed. Default serverUrl is http://jeeves.mother:8765. All network calls wrap exceptions so invalid URL/token never crashes — they just produce PostResult.Failed with the error message.
|
||||||
|
*** evidence
|
||||||
|
- commit: 19b05a8 (screen), 2a963d9 (wired to sync)
|
||||||
|
- tests: manual — set bad URL, tap sync, verify captures stay pending/failed with error; set correct URL + token, sync succeeds
|
||||||
|
- datetime: [2026-05-18 Sun 18:00]
|
||||||
|
|
||||||
* milestone 4: polish and hardening
|
* milestone 4: polish and hardening
|
||||||
** TODO make launch path fast
|
** DONE make launch path fast
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- app opens directly to capture field.
|
- app opens directly to capture field.
|
||||||
- no network call blocks launch.
|
- no network call blocks launch.
|
||||||
- no history loading blocks text entry.
|
- no history loading blocks text entry.
|
||||||
** TODO normalize tags
|
*** notes
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() } fires immediately on composition. No network calls in MainActivity or CaptureScreen init path. History loads via Room Flow on a background coroutine — never blocks text entry.
|
||||||
|
*** evidence
|
||||||
|
- commit: 19b05a8
|
||||||
|
- tests: manual — cold launch, keyboard appears without any loading state
|
||||||
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
|
** DONE normalize tags
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- whitespace-separated and comma-separated tags both work.
|
- whitespace-separated and comma-separated tags both work.
|
||||||
- invalid org tag chars are stripped or replaced.
|
- invalid org tag chars are stripped or replaced.
|
||||||
- duplicate tags collapse.
|
- duplicate tags collapse.
|
||||||
** TODO handle multiline notes well
|
*** notes
|
||||||
|
Two-layer normalization: parseTags() in CaptureViewModel splits on [,\s]+ before storing to Room. normalize_tags() in server/app/org_writer.py lowercases, strips #, replaces spaces with _, removes non-[a-z0-9_] chars, deduplicates. 7 unit tests cover all cases.
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00 (server), 19b05a8 (android)
|
||||||
|
- tests: pytest tests/test_org_writer.py::TestNormalizeTags — 7 passed
|
||||||
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
|
** DONE fix org formatting
|
||||||
|
*** acceptance
|
||||||
|
- add `#+startup: overview` to synq.org
|
||||||
|
*** notes
|
||||||
|
Append writer in main.py checks if org file exists before first write. If missing, creates it with #+title and #+startup: overview header. Existing files are never modified.
|
||||||
|
*** evidence
|
||||||
|
- commit: fd28a45
|
||||||
|
- tests: manual — delete synq.org, post a capture, verify header present
|
||||||
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
|
** DONE handle multiline notes well
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- first line can become note heading if body is multiline.
|
- first line can become note heading if body is multiline.
|
||||||
- full body is preserved under heading.
|
- full body is preserved under heading.
|
||||||
- todo body remains usable as a single todo heading, with extra lines under it if present.
|
- todo body remains usable as a single todo heading, with extra lines under it if present.
|
||||||
** TODO add basic logs
|
*** notes
|
||||||
|
format_capture() in org_writer.py: multiline note uses first line as heading suffix ("* note: <first line>"), full body preserved below drawer. Single-line note uses "* note" heading, body below. Covered by test_note_multiline.
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00
|
||||||
|
- tests: pytest tests/test_org_writer.py::TestFormatCapture::test_note_multiline — passed
|
||||||
|
- datetime: [2026-05-17 Sat 17:00]
|
||||||
|
|
||||||
|
** DONE add basic logs
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- server logs accepted capture id.
|
- server logs accepted capture id.
|
||||||
- server logs duplicate capture id.
|
- server logs duplicate capture id.
|
||||||
- server logs rejected payload without dumping secrets.
|
- server logs rejected payload without dumping secrets.
|
||||||
** TODO create backup note
|
*** notes
|
||||||
|
logger.info for accepted and duplicate (id only, no body). logger.warning for invalid token (client IP only, no token value logged). logger.warning for validation errors (field names only, no body content).
|
||||||
|
*** evidence
|
||||||
|
- commit: fd28a45
|
||||||
|
- tests: manual — send bad token, send empty body, check uvicorn stdout
|
||||||
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
|
** DONE create backup note
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- readme documents which paths need backup.
|
- readme documents which paths need backup.
|
||||||
- readme documents restore behavior.
|
- readme documents restore behavior.
|
||||||
|
*** notes
|
||||||
|
Added "backup" section to README.md covering synq.org, capture.sqlite3, token.txt. Restore section documents behavior when each file is lost individually.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: n/a
|
||||||
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
* milestone 5: optional v1.5
|
* milestone 5: optional v1.5 improvements
|
||||||
** TODO add android share target
|
** DONE add android share target
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- sharing text to app opens prefilled capture.
|
- sharing text to app opens prefilled capture.
|
||||||
- user can save as note or todo.
|
- user can save as note or todo.
|
||||||
|
*** notes
|
||||||
|
ACTION_SEND text/plain intent-filter added to MainActivity in manifest. MainActivity.onCreate extracts EXTRA_TEXT and passes it as a URL-encoded nav argument to the capture/{prefill} route. CaptureScreen applies it via LaunchedEffect on first composition.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — share any text from browser/notes to synq, verify it pre-fills capture body
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
** TODO add home screen quick capture widget
|
** TODO add home screen quick capture widget
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- widget opens capture screen quickly.
|
- widget opens capture screen quickly.
|
||||||
- does not require sync to work.
|
- does not require sync to work.
|
||||||
** TODO add recent tag chips
|
|
||||||
|
** DONE add recent tag chips
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- app shows recent tags.
|
- app shows recent tags.
|
||||||
- tapping chip adds/removes tag.
|
- tapping chip adds/removes tag.
|
||||||
** TODO add edit-before-sync
|
*** notes
|
||||||
|
CaptureViewModel.recentTags collects the last 100 capture tagsJson rows, decodes each JSON array, flattens and deduplicates (LinkedHashSet order), limits to 20. CaptureScreen shows a FlowRow of FilterChip below the tags text field. Tapping a chip calls toggleTag() which adds/removes it from the tags string. Active chips are highlighted via FilterChip selected=true.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — save a few captures with tags, open capture screen, verify chips appear and toggle
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
|
** DONE add edit-before-sync
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- pending/failed captures can be edited.
|
- pending/failed captures can be edited.
|
||||||
- synced captures are read-only unless explicitly duplicated.
|
- synced captures are read-only unless explicitly duplicated.
|
||||||
|
*** notes
|
||||||
|
CaptureDao.updateBody() new query. HistoryViewModel.updateBody() calls it. HistoryScreen CaptureRow shows "edit" OutlinedButton only when expanded AND status is pending/failed. Tapping edit opens an AlertDialog with an OutlinedTextField pre-filled with current body. Save calls updateBody; cancel dismisses.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — save a capture, open history, expand row, tap edit, change body, save, verify updated
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
|
** DONE tap to expand history item
|
||||||
|
*** acceptance
|
||||||
|
- tapping a history row expands/collapses it.
|
||||||
|
- expanded row shows full body.
|
||||||
|
- edit button is only visible when expanded.
|
||||||
|
*** notes
|
||||||
|
CaptureRow uses rememberSaveable expanded: Boolean keyed on capture.id. Card has clickable { expanded = !expanded }. maxLines switches between 2 and Int.MAX_VALUE. Edit button only renders when expanded && editable.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — tap history rows to expand/collapse, verify full body visible when expanded
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
|
** DONE add "Last synced" display
|
||||||
|
*** acceptance
|
||||||
|
- "last synced at" visible in history top bar and capture screen.
|
||||||
|
*** notes
|
||||||
|
CaptureDao.getLastSyncedAt() returns Flow<String?> of MAX(syncedAt). HistoryViewModel and CaptureViewModel both stateIn this flow. HistoryScreen shows it as a subtitle under "history" in the TopAppBar. CaptureScreen shows it as a small label above the save buttons. Both parse the ISO string via OffsetDateTime and format with DateTimeFormatter.ofLocalizedDateTime(SHORT).
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — sync a capture, verify timestamp appears in history header and capture screen
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
|
** DONE add settings for sync frequency
|
||||||
|
*** acceptance
|
||||||
|
- user-specified poll rate in minutes (min 15).
|
||||||
|
- changing interval reschedules WorkManager immediately.
|
||||||
|
*** notes
|
||||||
|
SynqSettings gained syncIntervalMinutes: Int = 15 field with DataStore key sync_interval_minutes. SettingsScreen added OutlinedTextField with number keyboard for interval, shows supporting text "WorkManager minimum is 15 min". buildSettings() coerces to max(value, 15). SettingsViewModel.save() now also calls SyncWorker.schedule(app, settings.syncIntervalMinutes). SyncWorker.schedule() uses REPLACE policy so new interval takes effect immediately.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — change interval to 30, save, verify WorkManager re-queues (check adb shell dumpsys jobscheduler)
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
* implementation notes for coding agents
|
* implementation notes for coding agents
|
||||||
- keep the server small and testable.
|
- keep the server small and testable.
|
||||||
|
|||||||
Reference in New Issue
Block a user