Compare commits

7 Commits
31 changed files with 665 additions and 374 deletions
-37
View File
@@ -1,37 +0,0 @@
name: Build and Publish Docker Image
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: ./CollabTableServer
file: ./CollabTableServer/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: |
gabriel20xx/collabtable:latest
gabriel20xx/collabtable:${{ github.sha }}
+73
View File
@@ -0,0 +1,73 @@
name: Server CI
on:
push:
branches: [ '**' ]
paths:
- 'CollabTableServer/**'
- '.github/workflows/server-ci.yml'
pull_request:
branches: [ '**' ]
paths:
- 'CollabTableServer/**'
- '.github/workflows/server-ci.yml'
workflow_dispatch:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: CollabTableServer
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: CollabTableServer/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
build-and-push:
name: Build & Push Docker Image
needs: lint
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./CollabTableServer
push: ${{ github.event_name == 'push' }}
tags: |
gabriel20xx/CollabTableServer:latest
gabriel20xx/CollabTableServer:${{ github.sha }}
platforms: linux/amd64,linux/arm64
@@ -53,6 +53,7 @@ class CollabTableApplication : Application() {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication) NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
NotificationPoller.start(this@CollabTableApplication) NotificationPoller.start(this@CollabTableApplication)
} }
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
NotificationPoller.stop() NotificationPoller.stop()
} }
@@ -110,7 +110,8 @@ object ApiClient {
val ensuredApi = if (rawUrl.contains("/api")) rawUrl else rawUrl.trimEnd('/') + "/api/" val ensuredApi = if (rawUrl.contains("/api")) rawUrl else rawUrl.trimEnd('/') + "/api/"
ensureTrailingSlash(ensuredApi) ensureTrailingSlash(ensuredApi)
} }
} catch (e: Exception) { } catch (error: Exception) {
Logger.w("HTTP", "Failed to normalize API URL '$rawUrl': ${error.message}")
ensureTrailingSlash(rawUrl) ensureTrailingSlash(rawUrl)
} }
} }
@@ -6,8 +6,8 @@ import com.collabtable.app.data.model.Item
import com.collabtable.app.data.model.ItemValue import com.collabtable.app.data.model.ItemValue
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query import retrofit2.http.Query
data class SyncRequest( data class SyncRequest(
@@ -47,6 +47,13 @@ interface FieldDao {
@Query("SELECT * FROM fields WHERE updatedAt >= :since") @Query("SELECT * FROM fields WHERE updatedAt >= :since")
suspend fun getFieldsUpdatedSince(since: Long): List<Field> suspend fun getFieldsUpdatedSince(since: Long): List<Field>
@Query("UPDATE fields SET `order` = :newOrder, updatedAt = :timestamp WHERE id = :fieldId")
suspend fun updateFieldOrder(
fieldId: String,
newOrder: Int,
timestamp: Long,
)
// Reorder fields in a single transaction for clarity and consistency // Reorder fields in a single transaction for clarity and consistency
@Transaction @Transaction
suspend fun reorderFieldsInTransaction( suspend fun reorderFieldsInTransaction(
@@ -54,12 +61,7 @@ interface FieldDao {
timestamp: Long, timestamp: Long,
) { ) {
reorderedFields.forEachIndexed { index, field -> reorderedFields.forEachIndexed { index, field ->
updateField( updateFieldOrder(field.id, index, timestamp)
field.copy(
order = index,
updatedAt = timestamp,
),
)
} }
} }
} }
@@ -16,7 +16,8 @@ enum class FieldType {
PERCENTAGE, PERCENTAGE,
// Selection types // Selection types
DROPDOWN, SELECT,
MULTI_SELECT,
AUTOCOMPLETE, AUTOCOMPLETE,
CHECKBOX, CHECKBOX,
SWITCH, SWITCH,
@@ -72,22 +73,23 @@ data class Field(
) { ) {
fun getType(): FieldType { fun getType(): FieldType {
val raw = fieldType.ifBlank { "TEXT" }.uppercase() val raw = fieldType.ifBlank { "TEXT" }.uppercase()
return try { val exact = runCatching { FieldType.valueOf(raw) }.getOrNull()
FieldType.valueOf(raw) if (exact != null) {
} catch (e: Exception) { return exact
// Handle legacy/synonym field types }
when (raw) { // Handle legacy/synonym field types
"STRING" -> FieldType.TEXT return when (raw) {
"PRICE" -> FieldType.CURRENCY "DROPDOWN" -> FieldType.SELECT
"AMOUNT" -> FieldType.NUMBER "STRING" -> FieldType.TEXT
"SIZE" -> FieldType.TEXT "PRICE" -> FieldType.CURRENCY
else -> FieldType.TEXT "AMOUNT" -> FieldType.NUMBER
} "SIZE" -> FieldType.TEXT
else -> FieldType.TEXT
} }
} }
fun getDropdownOptions(): List<String> = fun getSelectOptions(): List<String> =
if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) { if ((getType() == FieldType.SELECT || getType() == FieldType.MULTI_SELECT) && fieldOptions.isNotBlank()) {
fieldOptions.split("|") fieldOptions.split("|")
} else { } else {
emptyList() emptyList()
@@ -16,8 +16,8 @@ class PreferencesManager(
private val _serverUrl = MutableStateFlow(getServerUrl()) private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow() val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
private val _isFirstRun = MutableStateFlow(isFirstRun()) private val _isFirstRunFlow = MutableStateFlow(isFirstRun())
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRun.asStateFlow() val isFirstRunFlow: StateFlow<Boolean> = _isFirstRunFlow.asStateFlow()
private val _themeMode = MutableStateFlow(getThemeMode()) private val _themeMode = MutableStateFlow(getThemeMode())
val themeMode: StateFlow<String> = _themeMode.asStateFlow() val themeMode: StateFlow<String> = _themeMode.asStateFlow()
@@ -67,7 +67,7 @@ class PreferencesManager(
fun setIsFirstRun(isFirstRun: Boolean) { fun setIsFirstRun(isFirstRun: Boolean) {
prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply() prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply()
_isFirstRun.value = isFirstRun _isFirstRunFlow.value = isFirstRun
} }
fun getServerPassword(): String? = prefs.getString(KEY_SERVER_PASSWORD, null) fun getServerPassword(): String? = prefs.getString(KEY_SERVER_PASSWORD, null)
@@ -194,7 +194,7 @@ class PreferencesManager(
fun getColumnWidths(listId: String): Map<String, Float> { fun getColumnWidths(listId: String): Map<String, Float> {
val key = COLUMN_WIDTHS_PREFIX + listId val key = COLUMN_WIDTHS_PREFIX + listId
val raw = prefs.getString(key, null) ?: return emptyMap() val raw = prefs.getString(key, null) ?: return emptyMap()
return try { return runCatching {
val json = JSONObject(raw) val json = JSONObject(raw)
val map = mutableMapOf<String, Float>() val map = mutableMapOf<String, Float>()
val it = json.keys() val it = json.keys()
@@ -206,9 +206,7 @@ class PreferencesManager(
} }
} }
map map
} catch (e: Exception) { }.getOrDefault(emptyMap())
emptyMap()
}
} }
fun setColumnWidths( fun setColumnWidths(
@@ -229,7 +227,7 @@ class PreferencesManager(
fun getColumnAlignments(listId: String): Map<String, String> { fun getColumnAlignments(listId: String): Map<String, String> {
val key = COLUMN_ALIGN_PREFIX + listId val key = COLUMN_ALIGN_PREFIX + listId
val raw = prefs.getString(key, null) ?: return emptyMap() val raw = prefs.getString(key, null) ?: return emptyMap()
return try { return runCatching {
val json = JSONObject(raw) val json = JSONObject(raw)
val map = mutableMapOf<String, String>() val map = mutableMapOf<String, String>()
val it = json.keys() val it = json.keys()
@@ -245,9 +243,7 @@ class PreferencesManager(
map[fieldId] = normalized map[fieldId] = normalized
} }
map map
} catch (e: Exception) { }.getOrDefault(emptyMap())
emptyMap()
}
} }
fun setColumnAlignments( fun setColumnAlignments(
@@ -272,7 +268,10 @@ class PreferencesManager(
fun getDeviceId(): String { fun getDeviceId(): String {
val existing = prefs.getString(KEY_DEVICE_ID, null) val existing = prefs.getString(KEY_DEVICE_ID, null)
if (!existing.isNullOrBlank()) return existing if (!existing.isNullOrBlank()) return existing
val id = java.util.UUID.randomUUID().toString() val id =
java.util.UUID
.randomUUID()
.toString()
prefs.edit().putString(KEY_DEVICE_ID, id).apply() prefs.edit().putString(KEY_DEVICE_ID, id).apply()
return id return id
} }
@@ -312,14 +312,14 @@ class SyncRepository(
private fun isNetworkAvailable(): Boolean { private fun isNetworkAvailable(): Boolean {
val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return false val network = cm.activeNetwork
val caps = cm.getNetworkCapabilities(network) ?: return false val caps = network?.let { cm.getNetworkCapabilities(it) }
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) val hasInternet = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) val isValidated = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true
val hasTransport = val hasTransport =
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true
return hasInternet && isValidated && hasTransport return hasInternet && isValidated && hasTransport
} }
} }
@@ -1,8 +1,12 @@
package com.collabtable.app.notifications package com.collabtable.app.notifications
import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.collabtable.app.R import com.collabtable.app.R
object NotificationHelper { object NotificationHelper {
@@ -34,6 +38,15 @@ object NotificationHelper {
id: Int, id: Int,
builder: NotificationCompat.Builder, builder: NotificationCompat.Builder,
) { ) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
}
with(NotificationManagerCompat.from(context)) { with(NotificationManagerCompat.from(context)) {
notify(id, builder.build()) notify(id, builder.build())
} }
@@ -2,7 +2,6 @@ package com.collabtable.app.notifications
import android.content.Context import android.content.Context
import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.api.CollabTableApi
import com.collabtable.app.data.api.NotificationEvent import com.collabtable.app.data.api.NotificationEvent
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
@@ -23,44 +22,48 @@ object NotificationPoller {
val prefs = PreferencesManager.getInstance(appCtx) val prefs = PreferencesManager.getInstance(appCtx)
val api = ApiClient.api val api = ApiClient.api
val db = CollabTableDatabase.getDatabase(appCtx) val db = CollabTableDatabase.getDatabase(appCtx)
job = CoroutineScope(Dispatchers.IO).launch { job =
while (isActive) { CoroutineScope(Dispatchers.IO).launch {
try { while (isActive) {
// Skip if password missing (auth disabled) try {
val pwd = prefs.getServerPassword()?.trim() // Skip if password missing (auth disabled)
if (pwd.isNullOrBlank() || pwd == "\$password") { val pwd = prefs.getServerPassword()?.trim()
delay(2_000) if (pwd.isNullOrBlank() || pwd == "\$password") {
continue delay(2_000)
} continue
val since = prefs.getLastListNotifyCheckTimestamp()
val resp = api.pollNotifications(since)
if (resp.isSuccessful) {
val body = resp.body()
val events = body?.notifications.orEmpty()
if (events.isNotEmpty()) {
// Map and emit based on preferences
events.forEach { ev ->
val listId = ev.listId ?: return@forEach
val list = db.listDao().getListById(listId) ?: return@forEach
handleEvent(appCtx, ev, list.name, prefs)
}
} }
// Advance checkpoint to server timestamp to avoid re-processing val since = prefs.getLastListNotifyCheckTimestamp()
val stamp = body?.serverTimestamp ?: System.currentTimeMillis() val resp = api.pollNotifications(since)
prefs.setLastListNotifyCheckTimestamp(stamp) if (resp.isSuccessful) {
} else if (resp.code() == 401) { val body = resp.body()
// Unauthorized; back off by advancing timestamp minimally to avoid tight loop val events = body?.notifications.orEmpty()
delay(5_000) if (events.isNotEmpty()) {
// Map and emit based on preferences
events.forEach { ev ->
val listId = ev.listId ?: return@forEach
val list = db.listDao().getListById(listId) ?: return@forEach
handleEvent(appCtx, ev, list.name, prefs)
}
}
// Advance checkpoint to server timestamp to avoid re-processing
val stamp = body?.serverTimestamp ?: System.currentTimeMillis()
prefs.setLastListNotifyCheckTimestamp(stamp)
} else if (resp.code() == 401) {
// Unauthorized; back off by advancing timestamp minimally to avoid tight loop
delay(5_000)
}
} catch (e: Exception) {
try {
Logger.w("NotifPoll", "Polling failed: ${e.message}")
} catch (_: Exception) {
}
// brief backoff
delay(1_500)
} }
} catch (e: Exception) { val interval = prefs.syncPollIntervalMs.value
try { Logger.w("NotifPoll", "Polling failed: ${e.message}") } catch (_: Exception) {} delay(interval)
// brief backoff
delay(1_500)
} }
val interval = prefs.syncPollIntervalMs.value
delay(interval)
} }
}
} }
fun stop() { fun stop() {
@@ -76,20 +79,24 @@ object NotificationPoller {
) { ) {
// Respect notification switches by grouping semantics // Respect notification switches by grouping semantics
when (ev.entityType.lowercase()) { when (ev.entityType.lowercase()) {
"list" -> when (ev.eventType.lowercase()) { "list" ->
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName) when (ev.eventType.lowercase()) {
"updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(context, ev.listId ?: "", listName) "created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName)
"deleted" -> if (prefs.notifyListRemoved.value) NotificationHelper.showListRemoved(context, ev.listId ?: "", listName) "updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(context, ev.listId ?: "", listName)
} "deleted" -> if (prefs.notifyListRemoved.value) NotificationHelper.showListRemoved(context, ev.listId ?: "", listName)
}
// Treat fields/items/value changes as content updates // Treat fields/items/value changes as content updates
"field", "item", "value" -> if (prefs.notifyListContentUpdated.value) { "field", "item", "value" ->
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName) if (prefs.notifyListContentUpdated.value) {
}
else -> when (ev.eventType.lowercase()) {
"listcontentupdated" -> if (prefs.notifyListContentUpdated.value) {
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName) NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
} }
} else ->
when (ev.eventType.lowercase()) {
"listcontentupdated" ->
if (prefs.notifyListContentUpdated.value) {
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
}
}
} }
} }
} }
@@ -53,7 +53,7 @@ private fun isEmulator(): Boolean {
private fun toHealthUrl(rawApiUrl: String): String { private fun toHealthUrl(rawApiUrl: String): String {
// Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator // Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator
return try { return runCatching {
val uri = Uri.parse(rawApiUrl) val uri = Uri.parse(rawApiUrl)
val host = uri.host?.lowercase() val host = uri.host?.lowercase()
val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal") val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal")
@@ -63,7 +63,7 @@ private fun toHealthUrl(rawApiUrl: String): String {
val path = (uri.encodedPath ?: "/").trimEnd('/') val path = (uri.encodedPath ?: "/").trimEnd('/')
val healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health" val healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health"
base + healthPath base + healthPath
} catch (e: Exception) { }.getOrElse {
rawApiUrl.replace("/api/", "/health").replace("/api", "/health") rawApiUrl.replace("/api/", "/health").replace("/api", "/health")
} }
} }
@@ -101,7 +101,7 @@ fun ConnectionStatusAction(
ok = false ok = false
} }
resp.close() resp.close()
} catch (e: Exception) { } catch (ignored: Exception) {
ok = false ok = false
} }
delay(intervalMs) delay(intervalMs)
@@ -1,4 +1,9 @@
@file:Suppress("ktlint:standard:no-wildcard-imports") @file:Suppress(
"ktlint:standard:no-wildcard-imports",
"SwallowedException",
"UnusedParameter",
"UnusedPrivateMember",
)
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
@@ -51,7 +56,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@@ -226,6 +230,7 @@ fun ListDetailScreen(
floatingActionButton = {}, floatingActionButton = {},
) { padding -> ) { padding ->
// Apply filtering, sorting and grouping off the main thread and memoize the result // Apply filtering, sorting and grouping off the main thread and memoize the result
@Suppress("ProduceStateDoesNotAssignValue")
val transformed by produceState( val transformed by produceState(
initialValue = TransformedItems(emptyList(), mapOf("_all" to emptyList())), initialValue = TransformedItems(emptyList(), mapOf("_all" to emptyList())),
stableItems, stableItems,
@@ -235,7 +240,7 @@ fun ListDetailScreen(
filterField, filterField,
filterValue, filterValue,
) { ) {
value = val transformedItems =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
transformItems( transformItems(
items = stableItems, items = stableItems,
@@ -246,6 +251,7 @@ fun ListDetailScreen(
filterValue = filterValue, filterValue = filterValue,
) )
} }
value = transformedItems
} }
val processedItems = transformed.processed val processedItems = transformed.processed
@@ -550,17 +556,19 @@ fun ListDetailScreen(
} }
stableItems.isEmpty() -> { stableItems.isEmpty() -> {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize(), Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = stringResource(R.string.no_items), text = stringResource(R.string.no_items),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(horizontal = 24.dp), .fillMaxWidth()
.padding(horizontal = 24.dp),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
@@ -639,8 +647,8 @@ fun ListDetailScreen(
onAddField = { name, fieldType, fieldOptions -> onAddField = { name, fieldType, fieldOptions ->
viewModel.addField(name, fieldType, fieldOptions) viewModel.addField(name, fieldType, fieldOptions)
}, },
onUpdateField = { fieldId, name, fieldType, fieldOptions -> onUpdateField = { fieldId, name, fieldType, fieldOptions, alignment ->
viewModel.updateField(fieldId, name, fieldType, fieldOptions) viewModel.updateField(fieldId, name, fieldType, fieldOptions, alignment)
}, },
onUpdateAlignment = { fieldId, alignment -> onUpdateAlignment = { fieldId, alignment ->
viewModel.updateFieldAlignment(fieldId, alignment) viewModel.updateFieldAlignment(fieldId, alignment)
@@ -804,7 +812,8 @@ private fun getDisplayTextForMeasure(
com.collabtable.app.data.model.FieldType.TEXT, com.collabtable.app.data.model.FieldType.TEXT,
com.collabtable.app.data.model.FieldType.MULTILINE_TEXT, com.collabtable.app.data.model.FieldType.MULTILINE_TEXT,
com.collabtable.app.data.model.FieldType.NUMBER, com.collabtable.app.data.model.FieldType.NUMBER,
com.collabtable.app.data.model.FieldType.DROPDOWN, com.collabtable.app.data.model.FieldType.SELECT,
com.collabtable.app.data.model.FieldType.MULTI_SELECT,
com.collabtable.app.data.model.FieldType.AUTOCOMPLETE, com.collabtable.app.data.model.FieldType.AUTOCOMPLETE,
com.collabtable.app.data.model.FieldType.DURATION, com.collabtable.app.data.model.FieldType.DURATION,
com.collabtable.app.data.model.FieldType.LOCATION, com.collabtable.app.data.model.FieldType.LOCATION,
@@ -848,7 +857,8 @@ private fun fieldTypeToLabel(type: String): String =
"NUMBER" -> "Number" "NUMBER" -> "Number"
"CURRENCY", "PRICE" -> "Currency" "CURRENCY", "PRICE" -> "Currency"
"PERCENTAGE" -> "Percentage" "PERCENTAGE" -> "Percentage"
"DROPDOWN" -> "Dropdown" "SELECT", "Select" -> "Select"
"MULTI_SELECT" -> "Multi-select"
"AUTOCOMPLETE" -> "Autocomplete" "AUTOCOMPLETE" -> "Autocomplete"
"CHECKBOX" -> "Checkbox" "CHECKBOX" -> "Checkbox"
"SWITCH" -> "Switch" "SWITCH" -> "Switch"
@@ -1044,8 +1054,7 @@ fun ItemRow(
.border( .border(
width = 1.dp, width = 1.dp,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) ).fillMaxHeight()
.fillMaxHeight()
// Consistent minimum top/bottom padding regardless of row height // Consistent minimum top/bottom padding regardless of row height
.padding(horizontal = 8.dp, vertical = 8.dp), .padding(horizontal = 8.dp, vertical = 8.dp),
// Center horizontally based on alignment while always vertically centering // Center horizontally based on alignment while always vertically centering
@@ -1102,7 +1111,9 @@ fun ItemRow(
) )
} }
com.collabtable.app.data.model.FieldType.DROPDOWN -> { com.collabtable.app.data.model.FieldType.SELECT,
com.collabtable.app.data.model.FieldType.MULTI_SELECT,
-> {
Text( Text(
text = value?.value ?: "", text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -1576,9 +1587,16 @@ fun AddFieldDialog(
// Selection types // Selection types
DropdownMenuItem( DropdownMenuItem(
text = { Text("Dropdown") }, text = { Text("Select") },
onClick = { onClick = {
selectedFieldType = "DROPDOWN" selectedFieldType = "SELECT"
expanded = false
},
)
DropdownMenuItem(
text = { Text("Multi-select") },
onClick = {
selectedFieldType = "MULTI_SELECT"
expanded = false expanded = false
}, },
) )
@@ -1725,14 +1743,14 @@ fun AddFieldDialog(
} }
// Dropdown-specific options // Dropdown-specific options
if (selectedFieldType == "DROPDOWN") { if (selectedFieldType == "SELECT" || selectedFieldType == "MULTI_SELECT") {
OutlinedTextField( OutlinedTextField(
value = dropdownOptions, value = dropdownOptions,
onValueChange = { dropdownOptions = it }, onValueChange = { dropdownOptions = it },
label = { Text("Options (comma-separated)") }, label = { Text("Options (comma-separated)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Option 1, Option 2, Option 3") }, placeholder = { Text("Option 1, Option 2, Option 3") },
supportingText = { Text("Enter dropdown choices separated by commas") }, supportingText = { Text("Enter select choices separated by commas") },
) )
} }
@@ -1770,7 +1788,7 @@ fun AddFieldDialog(
val options = val options =
when (normalizedType) { when (normalizedType) {
"CURRENCY" -> currency.trim() "CURRENCY" -> currency.trim()
"DROPDOWN" -> "SELECT", "MULTI_SELECT" ->
dropdownOptions dropdownOptions
.split(",") .split(",")
.map { it.trim() } .map { it.trim() }
@@ -1790,7 +1808,7 @@ fun AddFieldDialog(
}, },
enabled = enabled =
fieldName.isNotBlank() && fieldName.isNotBlank() &&
(selectedFieldType.uppercase() !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()), (selectedFieldType.uppercase() !in listOf("SELECT", "MULTI_SELECT", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
) { ) {
Text(stringResource(R.string.add)) Text(stringResource(R.string.add))
} }
@@ -1812,7 +1830,7 @@ fun EditFieldDialog(
) { ) {
var name by remember { mutableStateOf(field.name) } var name by remember { mutableStateOf(field.name) }
var selectedFieldType by remember { mutableStateOf(field.fieldType.uppercase()) } var selectedFieldType by remember { mutableStateOf(field.fieldType.uppercase()) }
var dropdownOptions by remember { mutableStateOf(field.getDropdownOptions().joinToString(", ")) } var dropdownOptions by remember { mutableStateOf(field.getSelectOptions().joinToString(", ")) }
var currency by remember { mutableStateOf(field.getCurrency()) } var currency by remember { mutableStateOf(field.getCurrency()) }
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
// Alignment state persisted per list/field in PreferencesManager // Alignment state persisted per list/field in PreferencesManager
@@ -1902,9 +1920,16 @@ fun EditFieldDialog(
// Selection types // Selection types
DropdownMenuItem( DropdownMenuItem(
text = { Text("Dropdown") }, text = { Text("Select") },
onClick = { onClick = {
selectedFieldType = "DROPDOWN" selectedFieldType = "SELECT"
expanded = false
},
)
DropdownMenuItem(
text = { Text("Multi-select") },
onClick = {
selectedFieldType = "MULTI_SELECT"
expanded = false expanded = false
}, },
) )
@@ -2051,14 +2076,14 @@ fun EditFieldDialog(
} }
// Dropdown-specific options // Dropdown-specific options
if (selectedFieldType == "DROPDOWN") { if (selectedFieldType == "SELECT" || selectedFieldType == "MULTI_SELECT") {
OutlinedTextField( OutlinedTextField(
value = dropdownOptions, value = dropdownOptions,
onValueChange = { dropdownOptions = it }, onValueChange = { dropdownOptions = it },
label = { Text("Options (comma-separated)") }, label = { Text("Options (comma-separated)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Option 1, Option 2, Option 3") }, placeholder = { Text("Option 1, Option 2, Option 3") },
supportingText = { Text("Enter dropdown choices separated by commas") }, supportingText = { Text("Enter select choices separated by commas") },
) )
} }
@@ -2113,7 +2138,7 @@ fun EditFieldDialog(
val options = val options =
when (normalizedType) { when (normalizedType) {
"CURRENCY", "PRICE" -> currency.trim() "CURRENCY", "PRICE" -> currency.trim()
"DROPDOWN" -> "SELECT", "MULTI_SELECT" ->
dropdownOptions dropdownOptions
.split(",") .split(",")
.map { it.trim() } .map { it.trim() }
@@ -2139,7 +2164,7 @@ fun EditFieldDialog(
} }
prefs.setColumnAlignments(field.listId, current) prefs.setColumnAlignments(field.listId, current)
}, },
enabled = name.isNotBlank() && (selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()), enabled = name.isNotBlank() && (selectedFieldType !in listOf("SELECT", "MULTI_SELECT", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
) { ) {
Text("Update") Text("Update")
} }
@@ -2306,13 +2331,13 @@ fun FieldInput(
} }
} }
com.collabtable.app.data.model.FieldType.DROPDOWN -> { com.collabtable.app.data.model.FieldType.SELECT -> {
val options = field.getDropdownOptions() val options = field.getSelectOptions()
var dropdownExpanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = dropdownExpanded, expanded = expanded,
onExpandedChange = { dropdownExpanded = !dropdownExpanded }, onExpandedChange = { expanded = !expanded },
) { ) {
OutlinedTextField( OutlinedTextField(
value = value, value = value,
@@ -2320,7 +2345,7 @@ fun FieldInput(
readOnly = true, readOnly = true,
label = { Text(field.name) }, label = { Text(field.name) },
trailingIcon = { trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
}, },
modifier = modifier =
Modifier Modifier
@@ -2329,15 +2354,71 @@ fun FieldInput(
) )
ExposedDropdownMenu( ExposedDropdownMenu(
expanded = dropdownExpanded, expanded = expanded,
onDismissRequest = { dropdownExpanded = false }, onDismissRequest = { expanded = false },
) { ) {
options.forEach { option -> options.forEach { option ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(option) }, text = { Text(option) },
onClick = { onClick = {
onValueChange(option) onValueChange(option)
dropdownExpanded = false expanded = false
},
)
}
}
}
}
com.collabtable.app.data.model.FieldType.MULTI_SELECT -> {
val options = field.getSelectOptions()
var expanded by remember { mutableStateOf(false) }
val selectedOptions =
remember(value) {
value.split(", ").filter { it.isNotBlank() }.toSet()
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = value,
onValueChange = {},
readOnly = true,
label = { Text(field.name) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
modifier =
Modifier
.menuAnchor()
.fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
val isSelected = selectedOptions.contains(option)
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = isSelected, onCheckedChange = null)
Spacer(modifier = Modifier.width(8.dp))
Text(option)
}
},
onClick = {
val newSelected =
if (isSelected) {
selectedOptions - option
} else {
selectedOptions + option
}
val orderedNewSelected = options.filter { it in newSelected }
onValueChange(orderedNewSelected.joinToString(", "))
}, },
) )
} }
@@ -2802,7 +2883,7 @@ fun ManageColumnsDialog(
fields: List<Field>, fields: List<Field>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAddField: (String, String, String) -> Unit, onAddField: (String, String, String) -> Unit,
onUpdateField: (String, String, String, String) -> Unit, onUpdateField: (String, String, String, String, String) -> Unit,
onUpdateAlignment: (String, String) -> Unit, onUpdateAlignment: (String, String) -> Unit,
onDeleteField: (String) -> Unit, onDeleteField: (String) -> Unit,
onReorderFields: (List<Field>) -> Unit, onReorderFields: (List<Field>) -> Unit,
@@ -2992,8 +3073,7 @@ fun ManageColumnsDialog(
field = field, field = field,
onDismiss = { fieldToEdit = null }, onDismiss = { fieldToEdit = null },
onUpdate = { name, fieldType, fieldOptions, selectedAlignment -> onUpdate = { name, fieldType, fieldOptions, selectedAlignment ->
onUpdateField(field.id, name, fieldType, fieldOptions) onUpdateField(field.id, name, fieldType, fieldOptions, selectedAlignment)
onUpdateAlignment(field.id, selectedAlignment)
fieldToEdit = null fieldToEdit = null
}, },
) )
@@ -3070,8 +3150,10 @@ fun ColumnItem(
// Selection types // Selection types
com.collabtable.app.data.model.FieldType.CHECKBOX -> "Checkbox" com.collabtable.app.data.model.FieldType.CHECKBOX -> "Checkbox"
com.collabtable.app.data.model.FieldType.SWITCH -> "Switch" com.collabtable.app.data.model.FieldType.SWITCH -> "Switch"
com.collabtable.app.data.model.FieldType.DROPDOWN -> com.collabtable.app.data.model.FieldType.SELECT ->
"Dropdown (${field.getDropdownOptions().size} options)" "Select (${field.getSelectOptions().size} options)"
com.collabtable.app.data.model.FieldType.MULTI_SELECT ->
"Multi-select (${field.getSelectOptions().size} options)"
com.collabtable.app.data.model.FieldType.AUTOCOMPLETE -> com.collabtable.app.data.model.FieldType.AUTOCOMPLETE ->
"Autocomplete (${field.getAutocompleteOptions().size} options)" "Autocomplete (${field.getAutocompleteOptions().size} options)"
@@ -1,8 +1,6 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.content.Context import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.room.withTransaction import androidx.room.withTransaction
@@ -123,11 +121,6 @@ class ListDetailViewModel(
syncRepository.performSync() syncRepository.performSync()
} }
private fun isInForeground(): Boolean {
val state = ProcessLifecycleOwner.get().lifecycle.currentState
return state.isAtLeast(Lifecycle.State.STARTED)
}
// Local changes should not notify this same device; remote-change notification (future) will be triggered elsewhere. // Local changes should not notify this same device; remote-change notification (future) will be triggered elsewhere.
private fun maybeNotifyListContentUpdated() { /* intentionally disabled for local-origin changes */ } private fun maybeNotifyListContentUpdated() { /* intentionally disabled for local-origin changes */ }
@@ -224,17 +217,25 @@ class ListDetailViewModel(
name: String, name: String,
fieldType: String, fieldType: String,
fieldOptions: String, fieldOptions: String,
alignment: String,
) { ) {
viewModelScope.launch { viewModelScope.launch {
val field = database.fieldDao().getFieldById(fieldId) val field = database.fieldDao().getFieldById(fieldId)
if (field != null) { if (field != null) {
val ts = System.currentTimeMillis() val ts = System.currentTimeMillis()
val normalizedAlign =
when (alignment.lowercase()) {
"center" -> "center"
"end", "right" -> "end"
else -> "start"
}
database.withTransaction { database.withTransaction {
database.fieldDao().updateField( database.fieldDao().updateField(
field.copy( field.copy(
name = name, name = name,
fieldType = fieldType, fieldType = fieldType,
fieldOptions = fieldOptions, fieldOptions = fieldOptions,
alignment = normalizedAlign,
updatedAt = ts, updatedAt = ts,
), ),
) )
@@ -3,7 +3,9 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.Manifest import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -29,9 +31,6 @@ import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import android.net.Uri
import android.content.Intent
import androidx.documentfile.provider.DocumentFile
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -60,6 +59,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import com.collabtable.app.R import com.collabtable.app.R
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
@@ -72,6 +72,7 @@ import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable import org.burnoutcrew.reorderable.reorderable
@Suppress("UnusedParameter")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ListsScreen( fun ListsScreen(
@@ -276,7 +277,7 @@ fun ListsScreen(
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
) { ) {
itemsIndexed(working, key = { _, it -> it.id }) { _, list -> itemsIndexed(working, key = { _, table -> table.id }) { _, list ->
ReorderableItem(reorderState, key = list.id) { _ -> ReorderableItem(reorderState, key = list.id) { _ ->
Box(modifier = Modifier.animateItemPlacement()) { Box(modifier = Modifier.animateItemPlacement()) {
ListItem( ListItem(
@@ -349,32 +350,39 @@ fun ListsScreen(
} }
// Export dialog (top-level) separate from create/edit/delete dialogs // Export dialog (top-level) separate from create/edit/delete dialogs
listToExport?.let { list -> listToExport?.let { list ->
val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> val folderPicker =
if (uri != null) { rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
// Take persistable permission for potential reuse during this session if (uri != null) {
try { // Take persistable permission for potential reuse during this session
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION try {
context.contentResolver.takePersistableUriPermission(uri, flags) val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
} catch (_: Exception) { } context.contentResolver.takePersistableUriPermission(uri, flags)
exportTargetUri = uri } catch (_: Exception) {
}
exportTargetUri = uri
}
} }
}
ExportListDialog( ExportListDialog(
list = list, list = list,
selectedDirectoryLabel = exportTargetUri?.let { uri -> selectedDirectoryLabel =
DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)" exportTargetUri?.let { uri ->
} ?: "App storage (default)", DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)"
} ?: "App storage (default)",
onSelectDirectory = { folderPicker.launch(null) }, onSelectDirectory = { folderPicker.launch(null) },
onDismiss = { listToExport = null; exportTargetUri = null }, onDismiss = {
listToExport = null
exportTargetUri = null
},
onConfirmExport = { format -> onConfirmExport = { format ->
if (format == "CSV") { if (format == "CSV") {
coroutineScope.launch { coroutineScope.launch {
val result = viewModel.exportListToCsv(list.id, list.name, exportTargetUri) val result = viewModel.exportListToCsv(list.id, list.name, exportTargetUri)
result.onSuccess { path -> result
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show() .onSuccess { path ->
}.onFailure { Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show() }.onFailure {
} Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
}
listToExport = null listToExport = null
exportTargetUri = null exportTargetUri = null
} }
@@ -500,7 +508,6 @@ fun CreateListDialog(
} }
}, },
) )
} }
@Composable @Composable
@@ -612,7 +619,10 @@ private fun ExportListDialog(
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.format_csv)) }, text = { Text(stringResource(R.string.format_csv)) },
onClick = { selectedFormat = "CSV"; expanded = false }, onClick = {
selectedFormat = "CSV"
expanded = false
},
) )
} }
} }
@@ -1,6 +1,8 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.content.Context import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -19,12 +21,10 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.UUID
import java.util.Locale import java.util.Locale
import java.util.UUID
class ListsViewModel( class ListsViewModel(
private val database: CollabTableDatabase, private val database: CollabTableDatabase,
@@ -233,40 +233,48 @@ class ListsViewModel(
* stored in the app's external files directory. Returns a Result containing * stored in the app's external files directory. Returns a Result containing
* the absolute file path on success. * the absolute file path on success.
*/ */
@Suppress("ReturnCount")
suspend fun exportListToCsv( suspend fun exportListToCsv(
listId: String, listId: String,
listName: String, listName: String,
targetTreeUri: Uri? = null, targetTreeUri: Uri? = null,
): Result<String> { ): Result<String> {
return try { return try {
// Load fields (ordered) and items with values // Load fields (ordered) and items with values
val fields: List<Field> = database.fieldDao().getFieldsForList(listId).first() val fields: List<Field> = database.fieldDao().getFieldsForList(listId).first()
val items: List<ItemWithValues> = database.itemDao().getItemsWithValuesForList(listId).first() val items: List<ItemWithValues> = database.itemDao().getItemsWithValuesForList(listId).first()
// Build header // Build header
val headers = fields.map { escapeCsv(it.name) } val headers = fields.map { escapeCsv(it.name) }
val fieldIdOrder = fields.map { it.id } val fieldIdOrder = fields.map { it.id }
val rows = items.map { iwv -> val rows =
val valueMap = iwv.values.associateBy { it.fieldId } items.map { iwv ->
fieldIdOrder.joinToString(",") { fid -> val valueMap = iwv.values.associateBy { it.fieldId }
val raw = valueMap[fid]?.value ?: "" fieldIdOrder.joinToString(",") { fid ->
escapeCsv(raw) val raw = valueMap[fid]?.value ?: ""
} escapeCsv(raw)
} }
}
val csv = buildString { val csv =
append(headers.joinToString(",")) buildString {
append('\n') append(headers.joinToString(","))
rows.forEachIndexed { idx, row -> append('\n')
append(row) rows.forEachIndexed { idx, row ->
if (idx != rows.lastIndex) append('\n') append(row)
} if (idx != rows.lastIndex) append('\n')
} }
}
val safeBase = listName.lowercase(Locale.US).replace("[^a-z0-9]+".toRegex(), "_").trim('_').ifBlank { "table" } val safeBase =
val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) listName
val fileName = "${safeBase}_${ts}.csv" .lowercase(Locale.US)
.replace("[^a-z0-9]+".toRegex(), "_")
.trim('_')
.ifBlank { "table" }
val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileName = "${safeBase}_$ts.csv"
if (targetTreeUri != null) { if (targetTreeUri != null) {
val docTree = DocumentFile.fromTreeUri(context, targetTreeUri) val docTree = DocumentFile.fromTreeUri(context, targetTreeUri)
if (docTree == null || !docTree.isDirectory || !docTree.canWrite()) { if (docTree == null || !docTree.isDirectory || !docTree.canWrite()) {
@@ -1,3 +1,5 @@
@file:Suppress("SwallowedException")
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.net.Uri import android.net.Uri
@@ -64,6 +64,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@Suppress("UnusedParameter")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -453,7 +454,7 @@ fun SettingsScreen(
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = "View application logs", text = "View application logs",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -538,7 +539,8 @@ fun SettingsScreen(
onDismissRequest = { showLeaveDialog = false }, onDismissRequest = { showLeaveDialog = false },
title = { Text(stringResource(R.string.leave_server_dialog_title)) }, title = { Text(stringResource(R.string.leave_server_dialog_title)) },
text = { text = {
Text(stringResource(R.string.leave_server_dialog_body), Text(
stringResource(R.string.leave_server_dialog_body),
) )
}, },
confirmButton = { confirmButton = {
@@ -84,8 +84,13 @@ object Logger {
): Boolean { ): Boolean {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val last = _logs.value.lastOrNull() val last = _logs.value.lastOrNull()
val isDuplicateMessage =
last?.let { previous ->
previous.level == level && previous.tag == tag && previous.message == message
} ?: false
val withinDedupeWindow = last != null && (now - last.timestamp) <= DEDUPE_WINDOW_MS
// Suppress exact duplicate consecutive logs within a short window // Suppress exact duplicate consecutive logs within a short window
if (last != null && last.level == level && last.tag == tag && last.message == message && (now - last.timestamp) <= DEDUPE_WINDOW_MS) { if (isDuplicateMessage && withinDedupeWindow) {
return false return false
} }
val entry = LogEntry(timestamp = now, level = level, tag = tag, message = message) val entry = LogEntry(timestamp = now, level = level, tag = tag, message = message)
@@ -7,6 +7,7 @@ import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.notifications.NotificationHelper import com.collabtable.app.notifications.NotificationHelper
import com.collabtable.app.utils.Logger
class ListChangeNotificationWorker( class ListChangeNotificationWorker(
appContext: Context, appContext: Context,
@@ -28,7 +29,8 @@ class ListChangeNotificationWorker(
// Update checkpoint regardless to avoid duplicate spam // Update checkpoint regardless to avoid duplicate spam
prefs.setLastListNotifyCheckTimestamp(now) prefs.setLastListNotifyCheckTimestamp(now)
Result.success() Result.success()
} catch (e: Exception) { } catch (error: Exception) {
Logger.w("ListNotifyWorker", "Background notification polling failed: ${error.message}")
Result.retry() Result.retry()
} }
} }
+16
View File
@@ -33,3 +33,19 @@ PGPORT=5432
PGUSER=postgres PGUSER=postgres
PGPASSWORD= PGPASSWORD=
PGDATABASE=collabtable PGDATABASE=collabtable
#################################################################
# Startup resilience and sync/auth tuning
#################################################################
# DB init retry interval in milliseconds (default: 5000)
DB_INIT_RETRY_INTERVAL_MS=5000
# Max DB init retry attempts. 0 means retry forever (default: 0)
DB_INIT_RETRY_MAX_ATTEMPTS=0
# Suppress repetitive auth warning logs for the same path/method (default: 30000)
AUTH_LOG_THROTTLE_MS=30000
# Allow at most this much client clock skew into sync timestamps (default: 0)
# Keep this at 0 to prevent future-dated rows from being re-sent in hot loops.
SYNC_MAX_FUTURE_SKEW_MS=0
+4
View File
@@ -230,6 +230,10 @@ docker-compose up -d --build
- `DB_PATH` - Path to SQLite database file (default: ./data/collabtable.db for local, /data/collabtable.db in Docker) - `DB_PATH` - Path to SQLite database file (default: ./data/collabtable.db for local, /data/collabtable.db in Docker)
- `DATABASE_URL` - PostgreSQL connection URL (optional alternative to PG* variables) - `DATABASE_URL` - PostgreSQL connection URL (optional alternative to PG* variables)
- `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` - PostgreSQL connection parameters - `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` - PostgreSQL connection parameters
- `DB_INIT_RETRY_INTERVAL_MS` - Delay between DB init retries in ms (default: `5000`)
- `DB_INIT_RETRY_MAX_ATTEMPTS` - Max DB init retries; `0` retries forever (default: `0`)
- `AUTH_LOG_THROTTLE_MS` - Minimum interval before repeating the same auth warning log (default: `30000`)
- `SYNC_MAX_FUTURE_SKEW_MS` - Allowed future timestamp skew from clients during sync (default: `0`)
## Data Persistence ## Data Persistence
Binary file not shown.
+5
View File
@@ -14,6 +14,11 @@ services:
- DB_CLIENT=sqlite - DB_CLIENT=sqlite
# SQLite path (used when DB_CLIENT=sqlite) # SQLite path (used when DB_CLIENT=sqlite)
- DB_PATH=/data/collabtable.db - DB_PATH=/data/collabtable.db
# Retry DB initialization instead of crashing immediately on startup races
- DB_INIT_RETRY_INTERVAL_MS=5000
- DB_INIT_RETRY_MAX_ATTEMPTS=0
# Clamp future client timestamps during sync to avoid resend loops
- SYNC_MAX_FUTURE_SKEW_MS=0
# PostgreSQL (used when DB_CLIENT=postgres) - choose either DATABASE_URL or the PG* vars # PostgreSQL (used when DB_CLIENT=postgres) - choose either DATABASE_URL or the PG* vars
# - DATABASE_URL=postgres://user:password@postgres:5432/collabtable # - DATABASE_URL=postgres://user:password@postgres:5432/collabtable
# - PGHOST=postgres # - PGHOST=postgres
+4 -4
View File
@@ -27,7 +27,7 @@
"@types/node": "^20.10.6", "@types/node": "^20.10.6",
"@types/pg": "^8.10.2", "@types/pg": "^8.10.2",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "^5.3.3" "typescript": "5.5.4"
} }
}, },
"node_modules/@cspotcode/source-map-support": { "node_modules/@cspotcode/source-map-support": {
@@ -3607,9 +3607,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
+1 -1
View File
@@ -32,7 +32,7 @@
"@types/node": "^20.10.6", "@types/node": "^20.10.6",
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.11",
"@types/pg": "^8.10.2", "@types/pg": "^8.10.2",
"typescript": "^5.3.3", "typescript": "5.5.4",
"ts-node-dev": "^2.0.0" "ts-node-dev": "^2.0.0"
} }
} }
+1 -27
View File
@@ -296,33 +296,7 @@ export const dbAdapter: DBAdapter = clientType === 'postgres' || clientType ===
: new SqliteAdapter(); : new SqliteAdapter();
export async function initializeDatabase() { export async function initializeDatabase() {
const parsedRetries = parseInt(process.env.DB_CONNECT_RETRIES || '10', 10); await dbAdapter.initialize();
const maxRetries = Number.isNaN(parsedRetries) || parsedRetries < 1 ? 10 : parsedRetries;
const parsedDelay = parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS || '3000', 10);
const retryDelayMs = Number.isNaN(parsedDelay) || parsedDelay < 0 ? 3000 : parsedDelay;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await dbAdapter.initialize();
return;
} catch (err: any) {
const isConnectionError =
err?.code === 'ECONNREFUSED' ||
err?.code === 'ENOTFOUND' ||
err?.code === 'ETIMEDOUT';
if (!isConnectionError || attempt === maxRetries) {
throw err;
}
const delay = retryDelayMs * Math.pow(2, attempt - 1);
console.warn(
`Database connection attempt ${attempt}/${maxRetries} failed (${err.code || err.message}). Retrying in ${delay}ms...`
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
} }
export type { DBAdapter }; export type { DBAdapter };
+34 -1
View File
@@ -16,6 +16,39 @@ dotenv.config();
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const DB_INIT_RETRY_INTERVAL_MS = Math.max(250, Number(process.env.DB_INIT_RETRY_INTERVAL_MS || 5000));
const DB_INIT_RETRY_MAX_ATTEMPTS = Math.max(0, Number(process.env.DB_INIT_RETRY_MAX_ATTEMPTS || 0));
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function initializeDatabaseWithRetry() {
let attempt = 0;
for (;;) {
attempt += 1;
try {
await initializeDatabase();
if (attempt > 1) {
console.log(`[DB] Connected after ${attempt} attempt(s)`);
}
return;
} catch (error) {
const reachedLimit = DB_INIT_RETRY_MAX_ATTEMPTS > 0 && attempt >= DB_INIT_RETRY_MAX_ATTEMPTS;
console.error(`[DB] Initialization attempt ${attempt} failed`, error);
if (reachedLimit) {
throw error;
}
console.log(`[DB] Retrying in ${DB_INIT_RETRY_INTERVAL_MS}ms...`);
await sleep(DB_INIT_RETRY_INTERVAL_MS);
}
}
}
// Middleware // Middleware
app.use(cors()); app.use(cors());
@@ -44,7 +77,7 @@ app.use('/', webRoutes);
// Initialize database and start server // Initialize database and start server
(async () => { (async () => {
try { try {
await initializeDatabase(); await initializeDatabaseWithRetry();
console.log('Database synchronized successfully'); console.log('Database synchronized successfully');
console.log('Database models loaded:'); console.log('Database models loaded:');
console.log('- Lists'); console.log('- Lists');
+52 -20
View File
@@ -1,6 +1,33 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
let warningLogged = false; let warningLogged = false;
const AUTH_LOG_THROTTLE_MS = Math.max(1000, Number(process.env.AUTH_LOG_THROTTLE_MS || 30000));
const lastAuthLogAt = new Map<string, number>();
const commonProbePathPatterns = [
/^\/\./,
/client_secret\.json$/i,
/phpinfo/i,
/\.git/i,
/wp-admin/i,
/wp-login/i,
/id_rsa/i,
/config(\.|$)/i
];
function isCommonProbePath(pathname: string) {
return commonProbePathPatterns.some(pattern => pattern.test(pathname));
}
function shouldLogAuthWarning(kind: string, req: Request) {
const key = `${kind}:${req.method}:${req.path}`;
const now = Date.now();
const lastSeen = lastAuthLogAt.get(key) || 0;
if (now - lastSeen < AUTH_LOG_THROTTLE_MS) {
return false;
}
lastAuthLogAt.set(key, now);
return true;
}
export const authenticatePassword = (req: Request, res: Response, next: NextFunction) => { export const authenticatePassword = (req: Request, res: Response, next: NextFunction) => {
const serverPassword = process.env.SERVER_PASSWORD; const serverPassword = process.env.SERVER_PASSWORD;
@@ -18,12 +45,13 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader) { if (!authHeader) {
// Log diagnostic info to help trace intermittent 401s if (!isCommonProbePath(req.path) && shouldLogAuthWarning('missingHeader', req)) {
console.warn('[AUTH] Missing Authorization header', { console.warn('[AUTH] Missing Authorization header', {
path: req.path, path: req.path,
method: req.method, method: req.method,
ip: req.ip, ip: req.ip,
}); });
}
return res.status(401).json({ return res.status(401).json({
error: 'Unauthorized', error: 'Unauthorized',
message: 'No authorization header provided' message: 'No authorization header provided'
@@ -33,13 +61,15 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
// Parse the authorization header // Parse the authorization header
const parts = authHeader.split(' '); const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') { if (parts.length !== 2 || parts[0] !== 'Bearer') {
console.warn('[AUTH] Invalid authorization header format', { if (shouldLogAuthWarning('invalidHeaderFormat', req)) {
path: req.path, console.warn('[AUTH] Invalid authorization header format', {
method: req.method, path: req.path,
ip: req.ip, method: req.method,
// Do not log full header to avoid leaking secrets; include prefix only ip: req.ip,
headerPreview: authHeader.substring(0, Math.min(authHeader.length, 20)) + '...' // Do not log full header to avoid leaking secrets; include prefix only
}); headerPreview: authHeader.substring(0, Math.min(authHeader.length, 20)) + '...'
});
}
return res.status(401).json({ return res.status(401).json({
error: 'Unauthorized', error: 'Unauthorized',
message: 'Invalid authorization header format. Expected: Bearer <password>' message: 'Invalid authorization header format. Expected: Bearer <password>'
@@ -51,13 +81,15 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
// Validate password // Validate password
if (providedPassword !== serverPassword) { if (providedPassword !== serverPassword) {
// Avoid logging the full password; log only length and a small preview // Avoid logging the full password; log only length and a small preview
const safePreview = providedPassword ? `${providedPassword.substring(0, 2)}…(${providedPassword.length})` : 'null'; if (shouldLogAuthWarning('invalidPassword', req)) {
console.warn('[AUTH] Invalid password', { const safePreview = providedPassword ? `${providedPassword.substring(0, 2)}..(${providedPassword.length})` : 'null';
path: req.path, console.warn('[AUTH] Invalid password', {
method: req.method, path: req.path,
ip: req.ip, method: req.method,
providedPreview: safePreview, ip: req.ip,
}); providedPreview: safePreview,
});
}
return res.status(401).json({ return res.status(401).json({
error: 'Unauthorized', error: 'Unauthorized',
message: 'Invalid password' message: 'Invalid password'
+6 -2
View File
@@ -57,7 +57,9 @@ router.post('/values', async (req: Request, res: Response) => {
if (listId) { if (listId) {
await enqueueNotification(dbAdapter, (req as any).deviceId, 'listContentUpdated', 'value', id, listId, Date.now()); await enqueueNotification(dbAdapter, (req as any).deviceId, 'listContentUpdated', 'value', id, listId, Date.now());
} }
} catch {} } catch (notificationError) {
void notificationError;
}
res.json(itemValue); res.json(itemValue);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to save item value' }); res.status(500).json({ error: 'Failed to save item value' });
@@ -101,7 +103,9 @@ router.delete('/:id', async (req: Request, res: Response) => {
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]); const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]);
const listId = item ? ((item as any).listId ?? (item as any).listid) : undefined; const listId = item ? ((item as any).listId ?? (item as any).listid) : undefined;
await enqueueNotification(dbAdapter, (req as any).deviceId, 'deleted', 'item', req.params.id, listId, updatedAt); await enqueueNotification(dbAdapter, (req as any).deviceId, 'deleted', 'item', req.params.id, listId, updatedAt);
} catch {} } catch (notificationError) {
void notificationError;
}
res.json({ message: 'Item deleted successfully' }); res.json({ message: 'Item deleted successfully' });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to delete item' }); res.status(500).json({ error: 'Failed to delete item' });
+89 -39
View File
@@ -12,6 +12,36 @@ interface SyncRequest {
itemValues: any[]; itemValues: any[];
} }
const SYNC_MAX_FUTURE_SKEW_MS = Math.max(0, Number(process.env.SYNC_MAX_FUTURE_SKEW_MS || 0));
function normalizeTimestamp(raw: any, fallback: number, nowMs: number) {
const numeric = Number(raw);
if (!Number.isFinite(numeric) || numeric <= 0) {
return fallback;
}
let ts = Math.trunc(numeric);
if (ts < 1e12) {
ts *= 1000;
}
const maxAllowed = nowMs + SYNC_MAX_FUTURE_SKEW_MS;
if (ts > maxAllowed) {
return maxAllowed;
}
return ts;
}
function normalizeEntityTimestamps(createdAtRaw: any, updatedAtRaw: any, nowMs: number) {
const createdAt = normalizeTimestamp(createdAtRaw, nowMs, nowMs);
const updatedAt = normalizeTimestamp(updatedAtRaw, createdAt, nowMs);
return {
createdAt,
updatedAt: Math.max(createdAt, updatedAt)
};
}
// Stats tracking // Stats tracking
let syncStats = { let syncStats = {
totalSyncs: 0, totalSyncs: 0,
@@ -55,7 +85,7 @@ setInterval(() => {
lastReset: Date.now() lastReset: Date.now()
}; };
} }
}, 60000); // Changed from 30s to 60s }, 60000).unref?.(); // Changed from 30s to 60s
// Helper function to get prepared statements (lazy initialization) // Helper function to get prepared statements (lazy initialization)
// We'll perform upserts with positional params for cross-DB portability // We'll perform upserts with positional params for cross-DB portability
@@ -66,15 +96,23 @@ const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value,
router.post('/sync', async (req: Request, res: Response) => { router.post('/sync', async (req: Request, res: Response) => {
try { try {
const { lastSyncTimestamp, lists, fields, items, itemValues }: SyncRequest = req.body; const {
lastSyncTimestamp,
lists = [],
fields = [],
items = [],
itemValues = []
}: Partial<SyncRequest> = req.body || {};
const deviceId = (req as any).deviceId as string | undefined; const deviceId = (req as any).deviceId as string | undefined;
const nowMs = Date.now();
const safeLastSyncTimestamp = normalizeTimestamp(lastSyncTimestamp, 0, nowMs);
// Track stats // Track stats
syncStats.totalSyncs++; syncStats.totalSyncs++;
const incomingLists = lists?.length || 0; const incomingLists = lists.length;
const incomingFields = fields?.length || 0; const incomingFields = fields.length;
const incomingItems = items?.length || 0; const incomingItems = items.length;
const incomingValues = itemValues?.length || 0; const incomingValues = itemValues.length;
syncStats.listsReceived += incomingLists; syncStats.listsReceived += incomingLists;
syncStats.fieldsReceived += incomingFields; syncStats.fieldsReceived += incomingFields;
@@ -83,7 +121,7 @@ router.post('/sync', async (req: Request, res: Response) => {
// Only log significant sync events (not empty syncs) // Only log significant sync events (not empty syncs)
const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0; const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0;
const isInitialSync = lastSyncTimestamp === 0; const isInitialSync = safeLastSyncTimestamp === 0;
if (hasIncomingData) { if (hasIncomingData) {
const timestamp = new Date().toLocaleTimeString(); const timestamp = new Date().toLocaleTimeString();
@@ -92,7 +130,7 @@ router.post('/sync', async (req: Request, res: Response) => {
if (incomingLists > 0) { if (incomingLists > 0) {
console.log(` [LISTS] ${incomingLists} list(s)`); console.log(` [LISTS] ${incomingLists} list(s)`);
lists.forEach(list => { lists.forEach(list => {
const action = list.isDeleted ? 'Deleted' : (lastSyncTimestamp === 0 ? 'Created' : 'Updated'); const action = list.isDeleted ? 'Deleted' : (safeLastSyncTimestamp === 0 ? 'Created' : 'Updated');
console.log(` ${action}: "${list.name}"`); console.log(` ${action}: "${list.name}"`);
}); });
} }
@@ -117,25 +155,28 @@ router.post('/sync', async (req: Request, res: Response) => {
await dbAdapter.transaction(async (tx) => { await dbAdapter.transaction(async (tx) => {
if (lists && lists.length > 0) { if (lists && lists.length > 0) {
for (const list of lists) { for (const list of lists) {
const listTs = normalizeEntityTimestamps(list.createdAt, list.updatedAt, nowMs);
await tx.execute(UPSERT_LIST, [ await tx.execute(UPSERT_LIST, [
list.id, list.id,
list.name, list.name,
list.createdAt, listTs.createdAt,
list.updatedAt, listTs.updatedAt,
list.isDeleted ? 1 : 0 list.isDeleted ? 1 : 0
]); ]);
// enqueue list-level notifications // enqueue list-level notifications
try { try {
const ev = list.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated'); const ev = list.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, list.updatedAt); await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, listTs.updatedAt);
} catch {} } catch (notificationError) {
void notificationError;
}
// If a list was deleted, cascade the deletion to child records on server // If a list was deleted, cascade the deletion to child records on server
// - mark fields and items as deleted (tombstones) so other clients learn about them // - mark fields and items as deleted (tombstones) so other clients learn about them
// - remove item_values belonging to items under this list (no tombstone support for values) // - remove item_values belonging to items under this list (no tombstone support for values)
if (list.isDeleted) { if (list.isDeleted) {
try { try {
await tx.execute('UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]); await tx.execute('UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [listTs.updatedAt, list.id]);
await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]); await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [listTs.updatedAt, list.id]);
await tx.execute('DELETE FROM item_values WHERE itemId IN (SELECT id FROM items WHERE listId = ?)', [list.id]); await tx.execute('DELETE FROM item_values WHERE itemId IN (SELECT id FROM items WHERE listId = ?)', [list.id]);
} catch (err) { } catch (err) {
console.warn('[SYNC] Cascade delete for list failed:', String(err)); console.warn('[SYNC] Cascade delete for list failed:', String(err));
@@ -146,6 +187,7 @@ router.post('/sync', async (req: Request, res: Response) => {
if (fields && fields.length > 0) { if (fields && fields.length > 0) {
for (const field of fields) { for (const field of fields) {
const fieldTs = normalizeEntityTimestamps(field.createdAt, field.updatedAt, nowMs);
await tx.execute(UPSERT_FIELD, [ await tx.execute(UPSERT_FIELD, [
field.id, field.id,
field.name, field.name,
@@ -154,14 +196,16 @@ router.post('/sync', async (req: Request, res: Response) => {
(field.alignment ?? 'start'), (field.alignment ?? 'start'),
field.listId, field.listId,
field.order, field.order,
field.createdAt, fieldTs.createdAt,
field.updatedAt, fieldTs.updatedAt,
field.isDeleted ? 1 : 0 field.isDeleted ? 1 : 0
]); ]);
try { try {
const ev = field.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated'); const ev = field.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, field.updatedAt); await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, fieldTs.updatedAt);
} catch {} } catch (notificationError) {
void notificationError;
}
if (field.isDeleted) { if (field.isDeleted) {
// Remove item_values for this field // Remove item_values for this field
try { try {
@@ -175,7 +219,7 @@ router.post('/sync', async (req: Request, res: Response) => {
const remainingCount = remaining ? (remaining.cnt ?? remaining.CNT ?? remaining.count ?? 0) : 0; const remainingCount = remaining ? (remaining.cnt ?? remaining.CNT ?? remaining.count ?? 0) : 0;
if (remainingCount === 0) { if (remainingCount === 0) {
// Soft delete items in this list and purge their values // Soft delete items in this list and purge their values
await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [field.updatedAt, field.listId]); await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [fieldTs.updatedAt, field.listId]);
await tx.execute('DELETE FROM item_values WHERE itemId IN (SELECT id FROM items WHERE listId = ?)', [field.listId]); await tx.execute('DELETE FROM item_values WHERE itemId IN (SELECT id FROM items WHERE listId = ?)', [field.listId]);
console.log('[SYNC] Last field deleted; cascaded item+value cleanup for list', field.listId); console.log('[SYNC] Last field deleted; cascaded item+value cleanup for list', field.listId);
} }
@@ -188,17 +232,20 @@ router.post('/sync', async (req: Request, res: Response) => {
if (items && items.length > 0) { if (items && items.length > 0) {
for (const item of items) { for (const item of items) {
const itemTs = normalizeEntityTimestamps(item.createdAt, item.updatedAt, nowMs);
await tx.execute(UPSERT_ITEM, [ await tx.execute(UPSERT_ITEM, [
item.id, item.id,
item.listId, item.listId,
item.createdAt, itemTs.createdAt,
item.updatedAt, itemTs.updatedAt,
item.isDeleted ? 1 : 0 item.isDeleted ? 1 : 0
]); ]);
try { try {
const ev = item.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated'); const ev = item.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, item.updatedAt); await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, itemTs.updatedAt);
} catch {} } catch (notificationError) {
void notificationError;
}
// If an item was deleted, remove its values (no tombstone support for values) // If an item was deleted, remove its values (no tombstone support for values)
if (item.isDeleted) { if (item.isDeleted) {
try { try {
@@ -220,9 +267,9 @@ router.post('/sync', async (req: Request, res: Response) => {
// Helper to build IN clause placeholders (?,?,?) matching adapter's ? substitution // Helper to build IN clause placeholders (?,?,?) matching adapter's ? substitution
const makeIn = (arr: string[]) => arr.map(() => '?').join(', '); const makeIn = (arr: string[]) => arr.map(() => '?').join(', ');
let existingItemIds = new Set<string>(); let existingItemIds = new Set<string>();
let existingFieldIds = new Set<string>(); let existingFieldIds = new Set<string>();
const fieldToList: Record<string, string> = {}; const fieldToList: Record<string, string> = {};
try { try {
if (distinctItemIds.length > 0) { if (distinctItemIds.length > 0) {
const rows = await tx.queryAll(`SELECT id FROM items WHERE id IN (${makeIn(distinctItemIds)})`, distinctItemIds); const rows = await tx.queryAll(`SELECT id FROM items WHERE id IN (${makeIn(distinctItemIds)})`, distinctItemIds);
@@ -238,6 +285,7 @@ router.post('/sync', async (req: Request, res: Response) => {
} }
for (const value of itemValues) { for (const value of itemValues) {
const valueUpdatedAt = normalizeTimestamp(value.updatedAt, nowMs, nowMs);
const hasItem = existingItemIds.has(value.itemId); const hasItem = existingItemIds.has(value.itemId);
const hasField = existingFieldIds.has(value.fieldId); const hasField = existingFieldIds.has(value.fieldId);
if (!hasField) { if (!hasField) {
@@ -252,7 +300,7 @@ router.post('/sync', async (req: Request, res: Response) => {
orphanValues.push({ id: value.id, itemId: value.itemId, fieldId: value.fieldId, reason: 'missingItemNoList' }); orphanValues.push({ id: value.id, itemId: value.itemId, fieldId: value.fieldId, reason: 'missingItemNoList' });
continue; continue;
} }
const ts = value.updatedAt ?? Date.now(); const ts = valueUpdatedAt;
try { try {
await tx.execute(UPSERT_ITEM, [ await tx.execute(UPSERT_ITEM, [
value.itemId, value.itemId,
@@ -274,15 +322,17 @@ router.post('/sync', async (req: Request, res: Response) => {
value.itemId, value.itemId,
value.fieldId, value.fieldId,
value.value, value.value,
value.updatedAt valueUpdatedAt
]); ]);
// Coarse list content update notification // Coarse list content update notification
try { try {
const lId = fieldToList[value.fieldId]; const lId = fieldToList[value.fieldId];
if (lId) { if (lId) {
await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, value.updatedAt); await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, valueUpdatedAt);
} }
} catch {} } catch (notificationError) {
void notificationError;
}
} catch (err: any) { } catch (err: any) {
// Catch FK violation just in case race or deletion happened inside same sync // Catch FK violation just in case race or deletion happened inside same sync
if (err && err.code === '23503') { if (err && err.code === '23503') {
@@ -309,17 +359,17 @@ router.post('/sync', async (req: Request, res: Response) => {
// IMPORTANT: Include deleted items so clients can sync deletions // IMPORTANT: Include deleted items so clients can sync deletions
let serverLists, serverFields, serverItems, serverItemValues; let serverLists, serverFields, serverItems, serverItemValues;
if (lastSyncTimestamp === 0) { if (safeLastSyncTimestamp === 0) {
// Include deleted rows on initial sync to propagate tombstones // Include deleted rows on initial sync to propagate tombstones
serverLists = await dbAdapter.queryAll('SELECT * FROM lists'); serverLists = await dbAdapter.queryAll('SELECT * FROM lists');
serverFields = await dbAdapter.queryAll('SELECT * FROM fields'); serverFields = await dbAdapter.queryAll('SELECT * FROM fields');
serverItems = await dbAdapter.queryAll('SELECT * FROM items'); serverItems = await dbAdapter.queryAll('SELECT * FROM items');
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values'); serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values');
} else { } else {
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [lastSyncTimestamp]); serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [lastSyncTimestamp]); serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [lastSyncTimestamp]); serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [lastSyncTimestamp]); serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
} }
// Normalize output for clients (camelCase keys, ms timestamps, boolean isDeleted) // Normalize output for clients (camelCase keys, ms timestamps, boolean isDeleted)
@@ -383,7 +433,7 @@ router.post('/sync', async (req: Request, res: Response) => {
// Only log when sending significant data back // Only log when sending significant data back
const hasOutgoingData = (serverLists as any[]).length > 0 || (serverFields as any[]).length > 0 || (serverItems as any[]).length > 0 || (serverItemValues as any[]).length > 0; const hasOutgoingData = (serverLists as any[]).length > 0 || (serverFields as any[]).length > 0 || (serverItems as any[]).length > 0 || (serverItemValues as any[]).length > 0;
if (hasOutgoingData && !isInitialSync) { if (hasOutgoingData && !isInitialSync && hasIncomingData) {
console.log(` [OUT] Sending back: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`); console.log(` [OUT] Sending back: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
} else if (isInitialSync && hasOutgoingData) { } else if (isInitialSync && hasOutgoingData) {
console.log(` [OUT] Sending initial data: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`); console.log(` [OUT] Sending initial data: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);