Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57b14ef17 | ||
|
|
ebe5fc13c4 | ||
|
|
18dbcd372b | ||
|
|
10815300aa | ||
|
|
a8ee8477a5 | ||
|
|
3f6b56a3c3 | ||
|
|
01da9b14cf |
@@ -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 }}
|
||||
@@ -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)
|
||||
NotificationPoller.start(this@CollabTableApplication)
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
NotificationPoller.stop()
|
||||
}
|
||||
|
||||
@@ -110,7 +110,8 @@ object ApiClient {
|
||||
val ensuredApi = if (rawUrl.contains("/api")) rawUrl else rawUrl.trimEnd('/') + "/api/"
|
||||
ensureTrailingSlash(ensuredApi)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (error: Exception) {
|
||||
Logger.w("HTTP", "Failed to normalize API URL '$rawUrl': ${error.message}")
|
||||
ensureTrailingSlash(rawUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import com.collabtable.app.data.model.Item
|
||||
import com.collabtable.app.data.model.ItemValue
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
|
||||
data class SyncRequest(
|
||||
|
||||
@@ -47,6 +47,13 @@ interface FieldDao {
|
||||
@Query("SELECT * FROM fields WHERE updatedAt >= :since")
|
||||
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
|
||||
@Transaction
|
||||
suspend fun reorderFieldsInTransaction(
|
||||
@@ -54,12 +61,7 @@ interface FieldDao {
|
||||
timestamp: Long,
|
||||
) {
|
||||
reorderedFields.forEachIndexed { index, field ->
|
||||
updateField(
|
||||
field.copy(
|
||||
order = index,
|
||||
updatedAt = timestamp,
|
||||
),
|
||||
)
|
||||
updateFieldOrder(field.id, index, timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ enum class FieldType {
|
||||
PERCENTAGE,
|
||||
|
||||
// Selection types
|
||||
DROPDOWN,
|
||||
SELECT,
|
||||
MULTI_SELECT,
|
||||
AUTOCOMPLETE,
|
||||
CHECKBOX,
|
||||
SWITCH,
|
||||
@@ -72,11 +73,13 @@ data class Field(
|
||||
) {
|
||||
fun getType(): FieldType {
|
||||
val raw = fieldType.ifBlank { "TEXT" }.uppercase()
|
||||
return try {
|
||||
FieldType.valueOf(raw)
|
||||
} catch (e: Exception) {
|
||||
val exact = runCatching { FieldType.valueOf(raw) }.getOrNull()
|
||||
if (exact != null) {
|
||||
return exact
|
||||
}
|
||||
// Handle legacy/synonym field types
|
||||
when (raw) {
|
||||
return when (raw) {
|
||||
"DROPDOWN" -> FieldType.SELECT
|
||||
"STRING" -> FieldType.TEXT
|
||||
"PRICE" -> FieldType.CURRENCY
|
||||
"AMOUNT" -> FieldType.NUMBER
|
||||
@@ -84,10 +87,9 @@ data class Field(
|
||||
else -> FieldType.TEXT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getDropdownOptions(): List<String> =
|
||||
if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) {
|
||||
fun getSelectOptions(): List<String> =
|
||||
if ((getType() == FieldType.SELECT || getType() == FieldType.MULTI_SELECT) && fieldOptions.isNotBlank()) {
|
||||
fieldOptions.split("|")
|
||||
} else {
|
||||
emptyList()
|
||||
|
||||
+11
-12
@@ -16,8 +16,8 @@ class PreferencesManager(
|
||||
private val _serverUrl = MutableStateFlow(getServerUrl())
|
||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||
|
||||
private val _isFirstRun = MutableStateFlow(isFirstRun())
|
||||
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRun.asStateFlow()
|
||||
private val _isFirstRunFlow = MutableStateFlow(isFirstRun())
|
||||
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRunFlow.asStateFlow()
|
||||
|
||||
private val _themeMode = MutableStateFlow(getThemeMode())
|
||||
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||
@@ -67,7 +67,7 @@ class PreferencesManager(
|
||||
|
||||
fun setIsFirstRun(isFirstRun: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply()
|
||||
_isFirstRun.value = isFirstRun
|
||||
_isFirstRunFlow.value = isFirstRun
|
||||
}
|
||||
|
||||
fun getServerPassword(): String? = prefs.getString(KEY_SERVER_PASSWORD, null)
|
||||
@@ -194,7 +194,7 @@ class PreferencesManager(
|
||||
fun getColumnWidths(listId: String): Map<String, Float> {
|
||||
val key = COLUMN_WIDTHS_PREFIX + listId
|
||||
val raw = prefs.getString(key, null) ?: return emptyMap()
|
||||
return try {
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val map = mutableMapOf<String, Float>()
|
||||
val it = json.keys()
|
||||
@@ -206,9 +206,7 @@ class PreferencesManager(
|
||||
}
|
||||
}
|
||||
map
|
||||
} catch (e: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
fun setColumnWidths(
|
||||
@@ -229,7 +227,7 @@ class PreferencesManager(
|
||||
fun getColumnAlignments(listId: String): Map<String, String> {
|
||||
val key = COLUMN_ALIGN_PREFIX + listId
|
||||
val raw = prefs.getString(key, null) ?: return emptyMap()
|
||||
return try {
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val map = mutableMapOf<String, String>()
|
||||
val it = json.keys()
|
||||
@@ -245,9 +243,7 @@ class PreferencesManager(
|
||||
map[fieldId] = normalized
|
||||
}
|
||||
map
|
||||
} catch (e: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
fun setColumnAlignments(
|
||||
@@ -272,7 +268,10 @@ class PreferencesManager(
|
||||
fun getDeviceId(): String {
|
||||
val existing = prefs.getString(KEY_DEVICE_ID, null)
|
||||
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()
|
||||
return id
|
||||
}
|
||||
|
||||
+7
-7
@@ -312,14 +312,14 @@ class SyncRepository(
|
||||
|
||||
private fun isNetworkAvailable(): Boolean {
|
||||
val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
val network = cm.activeNetwork
|
||||
val caps = network?.let { cm.getNetworkCapabilities(it) }
|
||||
val hasInternet = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||
val isValidated = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true
|
||||
val hasTransport =
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true ||
|
||||
caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true ||
|
||||
caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true
|
||||
return hasInternet && isValidated && hasTransport
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -1,8 +1,12 @@
|
||||
package com.collabtable.app.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.collabtable.app.R
|
||||
|
||||
object NotificationHelper {
|
||||
@@ -34,6 +38,15 @@ object NotificationHelper {
|
||||
id: Int,
|
||||
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)) {
|
||||
notify(id, builder.build())
|
||||
}
|
||||
|
||||
+14
-7
@@ -2,7 +2,6 @@ package com.collabtable.app.notifications
|
||||
|
||||
import android.content.Context
|
||||
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.database.CollabTableDatabase
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
@@ -23,7 +22,8 @@ object NotificationPoller {
|
||||
val prefs = PreferencesManager.getInstance(appCtx)
|
||||
val api = ApiClient.api
|
||||
val db = CollabTableDatabase.getDatabase(appCtx)
|
||||
job = CoroutineScope(Dispatchers.IO).launch {
|
||||
job =
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
// Skip if password missing (auth disabled)
|
||||
@@ -53,7 +53,10 @@ object NotificationPoller {
|
||||
delay(5_000)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
try { Logger.w("NotifPoll", "Polling failed: ${e.message}") } catch (_: Exception) {}
|
||||
try {
|
||||
Logger.w("NotifPoll", "Polling failed: ${e.message}")
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
// brief backoff
|
||||
delay(1_500)
|
||||
}
|
||||
@@ -76,17 +79,21 @@ object NotificationPoller {
|
||||
) {
|
||||
// Respect notification switches by grouping semantics
|
||||
when (ev.entityType.lowercase()) {
|
||||
"list" -> when (ev.eventType.lowercase()) {
|
||||
"list" ->
|
||||
when (ev.eventType.lowercase()) {
|
||||
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(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
|
||||
"field", "item", "value" -> if (prefs.notifyListContentUpdated.value) {
|
||||
"field", "item", "value" ->
|
||||
if (prefs.notifyListContentUpdated.value) {
|
||||
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
|
||||
}
|
||||
else -> when (ev.eventType.lowercase()) {
|
||||
"listcontentupdated" -> if (prefs.notifyListContentUpdated.value) {
|
||||
else ->
|
||||
when (ev.eventType.lowercase()) {
|
||||
"listcontentupdated" ->
|
||||
if (prefs.notifyListContentUpdated.value) {
|
||||
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -53,7 +53,7 @@ private fun isEmulator(): Boolean {
|
||||
|
||||
private fun toHealthUrl(rawApiUrl: String): String {
|
||||
// Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator
|
||||
return try {
|
||||
return runCatching {
|
||||
val uri = Uri.parse(rawApiUrl)
|
||||
val host = uri.host?.lowercase()
|
||||
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 healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health"
|
||||
base + healthPath
|
||||
} catch (e: Exception) {
|
||||
}.getOrElse {
|
||||
rawApiUrl.replace("/api/", "/health").replace("/api", "/health")
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ fun ConnectionStatusAction(
|
||||
ok = false
|
||||
}
|
||||
resp.close()
|
||||
} catch (e: Exception) {
|
||||
} catch (ignored: Exception) {
|
||||
ok = false
|
||||
}
|
||||
delay(intervalMs)
|
||||
|
||||
+121
-39
@@ -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)
|
||||
|
||||
package com.collabtable.app.ui.screens
|
||||
@@ -51,7 +56,6 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -226,6 +230,7 @@ fun ListDetailScreen(
|
||||
floatingActionButton = {},
|
||||
) { padding ->
|
||||
// Apply filtering, sorting and grouping off the main thread and memoize the result
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val transformed by produceState(
|
||||
initialValue = TransformedItems(emptyList(), mapOf("_all" to emptyList())),
|
||||
stableItems,
|
||||
@@ -235,7 +240,7 @@ fun ListDetailScreen(
|
||||
filterField,
|
||||
filterValue,
|
||||
) {
|
||||
value =
|
||||
val transformedItems =
|
||||
withContext(Dispatchers.Default) {
|
||||
transformItems(
|
||||
items = stableItems,
|
||||
@@ -246,6 +251,7 @@ fun ListDetailScreen(
|
||||
filterValue = filterValue,
|
||||
)
|
||||
}
|
||||
value = transformedItems
|
||||
}
|
||||
|
||||
val processedItems = transformed.processed
|
||||
@@ -550,7 +556,8 @@ fun ListDetailScreen(
|
||||
}
|
||||
stableItems.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -558,7 +565,8 @@ fun ListDetailScreen(
|
||||
text = stringResource(R.string.no_items),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -639,8 +647,8 @@ fun ListDetailScreen(
|
||||
onAddField = { name, fieldType, fieldOptions ->
|
||||
viewModel.addField(name, fieldType, fieldOptions)
|
||||
},
|
||||
onUpdateField = { fieldId, name, fieldType, fieldOptions ->
|
||||
viewModel.updateField(fieldId, name, fieldType, fieldOptions)
|
||||
onUpdateField = { fieldId, name, fieldType, fieldOptions, alignment ->
|
||||
viewModel.updateField(fieldId, name, fieldType, fieldOptions, alignment)
|
||||
},
|
||||
onUpdateAlignment = { 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.MULTILINE_TEXT,
|
||||
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.DURATION,
|
||||
com.collabtable.app.data.model.FieldType.LOCATION,
|
||||
@@ -848,7 +857,8 @@ private fun fieldTypeToLabel(type: String): String =
|
||||
"NUMBER" -> "Number"
|
||||
"CURRENCY", "PRICE" -> "Currency"
|
||||
"PERCENTAGE" -> "Percentage"
|
||||
"DROPDOWN" -> "Dropdown"
|
||||
"SELECT", "Select" -> "Select"
|
||||
"MULTI_SELECT" -> "Multi-select"
|
||||
"AUTOCOMPLETE" -> "Autocomplete"
|
||||
"CHECKBOX" -> "Checkbox"
|
||||
"SWITCH" -> "Switch"
|
||||
@@ -1044,8 +1054,7 @@ fun ItemRow(
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
.fillMaxHeight()
|
||||
).fillMaxHeight()
|
||||
// Consistent minimum top/bottom padding regardless of row height
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
// 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 = value?.value ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -1576,9 +1587,16 @@ fun AddFieldDialog(
|
||||
|
||||
// Selection types
|
||||
DropdownMenuItem(
|
||||
text = { Text("Dropdown") },
|
||||
text = { Text("Select") },
|
||||
onClick = {
|
||||
selectedFieldType = "DROPDOWN"
|
||||
selectedFieldType = "SELECT"
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Multi-select") },
|
||||
onClick = {
|
||||
selectedFieldType = "MULTI_SELECT"
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
@@ -1725,14 +1743,14 @@ fun AddFieldDialog(
|
||||
}
|
||||
|
||||
// Dropdown-specific options
|
||||
if (selectedFieldType == "DROPDOWN") {
|
||||
if (selectedFieldType == "SELECT" || selectedFieldType == "MULTI_SELECT") {
|
||||
OutlinedTextField(
|
||||
value = dropdownOptions,
|
||||
onValueChange = { dropdownOptions = it },
|
||||
label = { Text("Options (comma-separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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 =
|
||||
when (normalizedType) {
|
||||
"CURRENCY" -> currency.trim()
|
||||
"DROPDOWN" ->
|
||||
"SELECT", "MULTI_SELECT" ->
|
||||
dropdownOptions
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
@@ -1790,7 +1808,7 @@ fun AddFieldDialog(
|
||||
},
|
||||
enabled =
|
||||
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))
|
||||
}
|
||||
@@ -1812,7 +1830,7 @@ fun EditFieldDialog(
|
||||
) {
|
||||
var name by remember { mutableStateOf(field.name) }
|
||||
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 expanded by remember { mutableStateOf(false) }
|
||||
// Alignment state persisted per list/field in PreferencesManager
|
||||
@@ -1902,9 +1920,16 @@ fun EditFieldDialog(
|
||||
|
||||
// Selection types
|
||||
DropdownMenuItem(
|
||||
text = { Text("Dropdown") },
|
||||
text = { Text("Select") },
|
||||
onClick = {
|
||||
selectedFieldType = "DROPDOWN"
|
||||
selectedFieldType = "SELECT"
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Multi-select") },
|
||||
onClick = {
|
||||
selectedFieldType = "MULTI_SELECT"
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
@@ -2051,14 +2076,14 @@ fun EditFieldDialog(
|
||||
}
|
||||
|
||||
// Dropdown-specific options
|
||||
if (selectedFieldType == "DROPDOWN") {
|
||||
if (selectedFieldType == "SELECT" || selectedFieldType == "MULTI_SELECT") {
|
||||
OutlinedTextField(
|
||||
value = dropdownOptions,
|
||||
onValueChange = { dropdownOptions = it },
|
||||
label = { Text("Options (comma-separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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 =
|
||||
when (normalizedType) {
|
||||
"CURRENCY", "PRICE" -> currency.trim()
|
||||
"DROPDOWN" ->
|
||||
"SELECT", "MULTI_SELECT" ->
|
||||
dropdownOptions
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
@@ -2139,7 +2164,7 @@ fun EditFieldDialog(
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -2306,13 +2331,13 @@ fun FieldInput(
|
||||
}
|
||||
}
|
||||
|
||||
com.collabtable.app.data.model.FieldType.DROPDOWN -> {
|
||||
val options = field.getDropdownOptions()
|
||||
var dropdownExpanded by remember { mutableStateOf(false) }
|
||||
com.collabtable.app.data.model.FieldType.SELECT -> {
|
||||
val options = field.getSelectOptions()
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = dropdownExpanded,
|
||||
onExpandedChange = { dropdownExpanded = !dropdownExpanded },
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
@@ -2320,7 +2345,7 @@ fun FieldInput(
|
||||
readOnly = true,
|
||||
label = { Text(field.name) },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -2329,15 +2354,71 @@ fun FieldInput(
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = dropdownExpanded,
|
||||
onDismissRequest = { dropdownExpanded = false },
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
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>,
|
||||
onDismiss: () -> Unit,
|
||||
onAddField: (String, String, String) -> Unit,
|
||||
onUpdateField: (String, String, String, String) -> Unit,
|
||||
onUpdateField: (String, String, String, String, String) -> Unit,
|
||||
onUpdateAlignment: (String, String) -> Unit,
|
||||
onDeleteField: (String) -> Unit,
|
||||
onReorderFields: (List<Field>) -> Unit,
|
||||
@@ -2992,8 +3073,7 @@ fun ManageColumnsDialog(
|
||||
field = field,
|
||||
onDismiss = { fieldToEdit = null },
|
||||
onUpdate = { name, fieldType, fieldOptions, selectedAlignment ->
|
||||
onUpdateField(field.id, name, fieldType, fieldOptions)
|
||||
onUpdateAlignment(field.id, selectedAlignment)
|
||||
onUpdateField(field.id, name, fieldType, fieldOptions, selectedAlignment)
|
||||
fieldToEdit = null
|
||||
},
|
||||
)
|
||||
@@ -3070,8 +3150,10 @@ fun ColumnItem(
|
||||
// Selection types
|
||||
com.collabtable.app.data.model.FieldType.CHECKBOX -> "Checkbox"
|
||||
com.collabtable.app.data.model.FieldType.SWITCH -> "Switch"
|
||||
com.collabtable.app.data.model.FieldType.DROPDOWN ->
|
||||
"Dropdown (${field.getDropdownOptions().size} options)"
|
||||
com.collabtable.app.data.model.FieldType.SELECT ->
|
||||
"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 ->
|
||||
"Autocomplete (${field.getAutocompleteOptions().size} options)"
|
||||
|
||||
|
||||
+8
-7
@@ -1,8 +1,6 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room.withTransaction
|
||||
@@ -123,11 +121,6 @@ class ListDetailViewModel(
|
||||
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.
|
||||
private fun maybeNotifyListContentUpdated() { /* intentionally disabled for local-origin changes */ }
|
||||
|
||||
@@ -224,17 +217,25 @@ class ListDetailViewModel(
|
||||
name: String,
|
||||
fieldType: String,
|
||||
fieldOptions: String,
|
||||
alignment: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val field = database.fieldDao().getFieldById(fieldId)
|
||||
if (field != null) {
|
||||
val ts = System.currentTimeMillis()
|
||||
val normalizedAlign =
|
||||
when (alignment.lowercase()) {
|
||||
"center" -> "center"
|
||||
"end", "right" -> "end"
|
||||
else -> "start"
|
||||
}
|
||||
database.withTransaction {
|
||||
database.fieldDao().updateField(
|
||||
field.copy(
|
||||
name = name,
|
||||
fieldType = fieldType,
|
||||
fieldOptions = fieldOptions,
|
||||
alignment = normalizedAlign,
|
||||
updatedAt = ts,
|
||||
),
|
||||
)
|
||||
|
||||
+21
-11
@@ -3,7 +3,9 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
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.List
|
||||
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.CircularProgressIndicator
|
||||
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.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.collabtable.app.R
|
||||
import com.collabtable.app.data.database.CollabTableDatabase
|
||||
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.reorderable
|
||||
|
||||
@Suppress("UnusedParameter")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ListsScreen(
|
||||
@@ -276,7 +277,7 @@ fun ListsScreen(
|
||||
contentPadding = PaddingValues(vertical = 8.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) { _ ->
|
||||
Box(modifier = Modifier.animateItemPlacement()) {
|
||||
ListItem(
|
||||
@@ -349,28 +350,35 @@ fun ListsScreen(
|
||||
}
|
||||
// Export dialog (top-level) separate from create/edit/delete dialogs
|
||||
listToExport?.let { list ->
|
||||
val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
val folderPicker =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null) {
|
||||
// Take persistable permission for potential reuse during this session
|
||||
try {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
} catch (_: Exception) { }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
exportTargetUri = uri
|
||||
}
|
||||
}
|
||||
ExportListDialog(
|
||||
list = list,
|
||||
selectedDirectoryLabel = exportTargetUri?.let { uri ->
|
||||
selectedDirectoryLabel =
|
||||
exportTargetUri?.let { uri ->
|
||||
DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)"
|
||||
} ?: "App storage (default)",
|
||||
onSelectDirectory = { folderPicker.launch(null) },
|
||||
onDismiss = { listToExport = null; exportTargetUri = null },
|
||||
onDismiss = {
|
||||
listToExport = null
|
||||
exportTargetUri = null
|
||||
},
|
||||
onConfirmExport = { format ->
|
||||
if (format == "CSV") {
|
||||
coroutineScope.launch {
|
||||
val result = viewModel.exportListToCsv(list.id, list.name, exportTargetUri)
|
||||
result.onSuccess { path ->
|
||||
result
|
||||
.onSuccess { path ->
|
||||
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
|
||||
}.onFailure {
|
||||
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
|
||||
@@ -500,7 +508,6 @@ fun CreateListDialog(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -612,7 +619,10 @@ private fun ExportListDialog(
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.format_csv)) },
|
||||
onClick = { selectedFormat = "CSV"; expanded = false },
|
||||
onClick = {
|
||||
selectedFormat = "CSV"
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-7
@@ -1,6 +1,8 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -19,12 +21,10 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
class ListsViewModel(
|
||||
private val database: CollabTableDatabase,
|
||||
@@ -233,6 +233,7 @@ class ListsViewModel(
|
||||
* stored in the app's external files directory. Returns a Result containing
|
||||
* the absolute file path on success.
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
suspend fun exportListToCsv(
|
||||
listId: String,
|
||||
listName: String,
|
||||
@@ -247,7 +248,8 @@ class ListsViewModel(
|
||||
val headers = fields.map { escapeCsv(it.name) }
|
||||
val fieldIdOrder = fields.map { it.id }
|
||||
|
||||
val rows = items.map { iwv ->
|
||||
val rows =
|
||||
items.map { iwv ->
|
||||
val valueMap = iwv.values.associateBy { it.fieldId }
|
||||
fieldIdOrder.joinToString(",") { fid ->
|
||||
val raw = valueMap[fid]?.value ?: ""
|
||||
@@ -255,7 +257,8 @@ class ListsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
val csv = buildString {
|
||||
val csv =
|
||||
buildString {
|
||||
append(headers.joinToString(","))
|
||||
append('\n')
|
||||
rows.forEachIndexed { idx, row ->
|
||||
@@ -264,9 +267,14 @@ class ListsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
val safeBase = listName.lowercase(Locale.US).replace("[^a-z0-9]+".toRegex(), "_").trim('_').ifBlank { "table" }
|
||||
val safeBase =
|
||||
listName
|
||||
.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"
|
||||
val fileName = "${safeBase}_$ts.csv"
|
||||
if (targetTreeUri != null) {
|
||||
val docTree = DocumentFile.fromTreeUri(context, targetTreeUri)
|
||||
if (docTree == null || !docTree.isDirectory || !docTree.canWrite()) {
|
||||
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("SwallowedException")
|
||||
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
+3
-1
@@ -64,6 +64,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Suppress("UnusedParameter")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -538,7 +539,8 @@ fun SettingsScreen(
|
||||
onDismissRequest = { showLeaveDialog = false },
|
||||
title = { Text(stringResource(R.string.leave_server_dialog_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.leave_server_dialog_body),
|
||||
Text(
|
||||
stringResource(R.string.leave_server_dialog_body),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -84,8 +84,13 @@ object Logger {
|
||||
): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
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
|
||||
if (last != null && last.level == level && last.tag == tag && last.message == message && (now - last.timestamp) <= DEDUPE_WINDOW_MS) {
|
||||
if (isDuplicateMessage && withinDedupeWindow) {
|
||||
return false
|
||||
}
|
||||
val entry = LogEntry(timestamp = now, level = level, tag = tag, message = message)
|
||||
|
||||
+3
-1
@@ -7,6 +7,7 @@ import com.collabtable.app.data.database.CollabTableDatabase
|
||||
import com.collabtable.app.data.model.CollabList
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.notifications.NotificationHelper
|
||||
import com.collabtable.app.utils.Logger
|
||||
|
||||
class ListChangeNotificationWorker(
|
||||
appContext: Context,
|
||||
@@ -28,7 +29,8 @@ class ListChangeNotificationWorker(
|
||||
// Update checkpoint regardless to avoid duplicate spam
|
||||
prefs.setLastListNotifyCheckTimestamp(now)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
} catch (error: Exception) {
|
||||
Logger.w("ListNotifyWorker", "Background notification polling failed: ${error.message}")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,3 +33,19 @@ PGPORT=5432
|
||||
PGUSER=postgres
|
||||
PGPASSWORD=
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
- `DATABASE_URL` - PostgreSQL connection URL (optional alternative to PG* variables)
|
||||
- `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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -14,6 +14,11 @@ services:
|
||||
- DB_CLIENT=sqlite
|
||||
# SQLite path (used when DB_CLIENT=sqlite)
|
||||
- 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
|
||||
# - DATABASE_URL=postgres://user:password@postgres:5432/collabtable
|
||||
# - PGHOST=postgres
|
||||
|
||||
Generated
+4
-4
@@ -27,7 +27,7 @@
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/pg": "^8.10.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
@@ -3607,9 +3607,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
||||
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/pg": "^8.10.2",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript": "5.5.4",
|
||||
"ts-node-dev": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,33 +296,7 @@ export const dbAdapter: DBAdapter = clientType === 'postgres' || clientType ===
|
||||
: new SqliteAdapter();
|
||||
|
||||
export async function initializeDatabase() {
|
||||
const parsedRetries = parseInt(process.env.DB_CONNECT_RETRIES || '10', 10);
|
||||
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 };
|
||||
|
||||
@@ -16,6 +16,39 @@ dotenv.config();
|
||||
|
||||
const app = express();
|
||||
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
|
||||
app.use(cors());
|
||||
@@ -44,7 +77,7 @@ app.use('/', webRoutes);
|
||||
// Initialize database and start server
|
||||
(async () => {
|
||||
try {
|
||||
await initializeDatabase();
|
||||
await initializeDatabaseWithRetry();
|
||||
console.log('Database synchronized successfully');
|
||||
console.log('Database models loaded:');
|
||||
console.log('- Lists');
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
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) => {
|
||||
const serverPassword = process.env.SERVER_PASSWORD;
|
||||
@@ -18,12 +45,13 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
// Log diagnostic info to help trace intermittent 401s
|
||||
if (!isCommonProbePath(req.path) && shouldLogAuthWarning('missingHeader', req)) {
|
||||
console.warn('[AUTH] Missing Authorization header', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
});
|
||||
}
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'No authorization header provided'
|
||||
@@ -33,6 +61,7 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
|
||||
// Parse the authorization header
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
if (shouldLogAuthWarning('invalidHeaderFormat', req)) {
|
||||
console.warn('[AUTH] Invalid authorization header format', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
@@ -40,6 +69,7 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
|
||||
// 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({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid authorization header format. Expected: Bearer <password>'
|
||||
@@ -51,13 +81,15 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
|
||||
// Validate password
|
||||
if (providedPassword !== serverPassword) {
|
||||
// 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)) {
|
||||
const safePreview = providedPassword ? `${providedPassword.substring(0, 2)}..(${providedPassword.length})` : 'null';
|
||||
console.warn('[AUTH] Invalid password', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
providedPreview: safePreview,
|
||||
});
|
||||
}
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid password'
|
||||
|
||||
@@ -57,7 +57,9 @@ router.post('/values', async (req: Request, res: Response) => {
|
||||
if (listId) {
|
||||
await enqueueNotification(dbAdapter, (req as any).deviceId, 'listContentUpdated', 'value', id, listId, Date.now());
|
||||
}
|
||||
} catch {}
|
||||
} catch (notificationError) {
|
||||
void notificationError;
|
||||
}
|
||||
res.json(itemValue);
|
||||
} catch (error) {
|
||||
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 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);
|
||||
} catch {}
|
||||
} catch (notificationError) {
|
||||
void notificationError;
|
||||
}
|
||||
res.json({ message: 'Item deleted successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete item' });
|
||||
|
||||
@@ -12,6 +12,36 @@ interface SyncRequest {
|
||||
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
|
||||
let syncStats = {
|
||||
totalSyncs: 0,
|
||||
@@ -55,7 +85,7 @@ setInterval(() => {
|
||||
lastReset: Date.now()
|
||||
};
|
||||
}
|
||||
}, 60000); // Changed from 30s to 60s
|
||||
}, 60000).unref?.(); // Changed from 30s to 60s
|
||||
|
||||
// Helper function to get prepared statements (lazy initialization)
|
||||
// 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) => {
|
||||
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 nowMs = Date.now();
|
||||
const safeLastSyncTimestamp = normalizeTimestamp(lastSyncTimestamp, 0, nowMs);
|
||||
|
||||
// Track stats
|
||||
syncStats.totalSyncs++;
|
||||
const incomingLists = lists?.length || 0;
|
||||
const incomingFields = fields?.length || 0;
|
||||
const incomingItems = items?.length || 0;
|
||||
const incomingValues = itemValues?.length || 0;
|
||||
const incomingLists = lists.length;
|
||||
const incomingFields = fields.length;
|
||||
const incomingItems = items.length;
|
||||
const incomingValues = itemValues.length;
|
||||
|
||||
syncStats.listsReceived += incomingLists;
|
||||
syncStats.fieldsReceived += incomingFields;
|
||||
@@ -83,7 +121,7 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
|
||||
// Only log significant sync events (not empty syncs)
|
||||
const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0;
|
||||
const isInitialSync = lastSyncTimestamp === 0;
|
||||
const isInitialSync = safeLastSyncTimestamp === 0;
|
||||
|
||||
if (hasIncomingData) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
@@ -92,7 +130,7 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
if (incomingLists > 0) {
|
||||
console.log(` [LISTS] ${incomingLists} list(s)`);
|
||||
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}"`);
|
||||
});
|
||||
}
|
||||
@@ -117,25 +155,28 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
await dbAdapter.transaction(async (tx) => {
|
||||
if (lists && lists.length > 0) {
|
||||
for (const list of lists) {
|
||||
const listTs = normalizeEntityTimestamps(list.createdAt, list.updatedAt, nowMs);
|
||||
await tx.execute(UPSERT_LIST, [
|
||||
list.id,
|
||||
list.name,
|
||||
list.createdAt,
|
||||
list.updatedAt,
|
||||
listTs.createdAt,
|
||||
listTs.updatedAt,
|
||||
list.isDeleted ? 1 : 0
|
||||
]);
|
||||
// enqueue list-level notifications
|
||||
try {
|
||||
const ev = list.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated');
|
||||
await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, list.updatedAt);
|
||||
} catch {}
|
||||
const ev = list.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
|
||||
await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, listTs.updatedAt);
|
||||
} catch (notificationError) {
|
||||
void notificationError;
|
||||
}
|
||||
// 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
|
||||
// - remove item_values belonging to items under this list (no tombstone support for values)
|
||||
if (list.isDeleted) {
|
||||
try {
|
||||
await tx.execute('UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]);
|
||||
await tx.execute('UPDATE items 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 = ?', [listTs.updatedAt, list.id]);
|
||||
await tx.execute('DELETE FROM item_values WHERE itemId IN (SELECT id FROM items WHERE listId = ?)', [list.id]);
|
||||
} catch (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) {
|
||||
for (const field of fields) {
|
||||
const fieldTs = normalizeEntityTimestamps(field.createdAt, field.updatedAt, nowMs);
|
||||
await tx.execute(UPSERT_FIELD, [
|
||||
field.id,
|
||||
field.name,
|
||||
@@ -154,14 +196,16 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
(field.alignment ?? 'start'),
|
||||
field.listId,
|
||||
field.order,
|
||||
field.createdAt,
|
||||
field.updatedAt,
|
||||
fieldTs.createdAt,
|
||||
fieldTs.updatedAt,
|
||||
field.isDeleted ? 1 : 0
|
||||
]);
|
||||
try {
|
||||
const ev = field.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated');
|
||||
await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, field.updatedAt);
|
||||
} catch {}
|
||||
const ev = field.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
|
||||
await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, fieldTs.updatedAt);
|
||||
} catch (notificationError) {
|
||||
void notificationError;
|
||||
}
|
||||
if (field.isDeleted) {
|
||||
// Remove item_values for this field
|
||||
try {
|
||||
@@ -175,7 +219,7 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
const remainingCount = remaining ? (remaining.cnt ?? remaining.CNT ?? remaining.count ?? 0) : 0;
|
||||
if (remainingCount === 0) {
|
||||
// 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]);
|
||||
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) {
|
||||
for (const item of items) {
|
||||
const itemTs = normalizeEntityTimestamps(item.createdAt, item.updatedAt, nowMs);
|
||||
await tx.execute(UPSERT_ITEM, [
|
||||
item.id,
|
||||
item.listId,
|
||||
item.createdAt,
|
||||
item.updatedAt,
|
||||
itemTs.createdAt,
|
||||
itemTs.updatedAt,
|
||||
item.isDeleted ? 1 : 0
|
||||
]);
|
||||
try {
|
||||
const ev = item.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated');
|
||||
await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, item.updatedAt);
|
||||
} catch {}
|
||||
const ev = item.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
|
||||
await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, itemTs.updatedAt);
|
||||
} catch (notificationError) {
|
||||
void notificationError;
|
||||
}
|
||||
// If an item was deleted, remove its values (no tombstone support for values)
|
||||
if (item.isDeleted) {
|
||||
try {
|
||||
@@ -238,6 +285,7 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
for (const value of itemValues) {
|
||||
const valueUpdatedAt = normalizeTimestamp(value.updatedAt, nowMs, nowMs);
|
||||
const hasItem = existingItemIds.has(value.itemId);
|
||||
const hasField = existingFieldIds.has(value.fieldId);
|
||||
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' });
|
||||
continue;
|
||||
}
|
||||
const ts = value.updatedAt ?? Date.now();
|
||||
const ts = valueUpdatedAt;
|
||||
try {
|
||||
await tx.execute(UPSERT_ITEM, [
|
||||
value.itemId,
|
||||
@@ -274,15 +322,17 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
value.itemId,
|
||||
value.fieldId,
|
||||
value.value,
|
||||
value.updatedAt
|
||||
valueUpdatedAt
|
||||
]);
|
||||
// Coarse list content update notification
|
||||
try {
|
||||
const lId = fieldToList[value.fieldId];
|
||||
if (lId) {
|
||||
await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, value.updatedAt);
|
||||
await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, valueUpdatedAt);
|
||||
}
|
||||
} catch (notificationError) {
|
||||
void notificationError;
|
||||
}
|
||||
} catch {}
|
||||
} catch (err: any) {
|
||||
// Catch FK violation just in case race or deletion happened inside same sync
|
||||
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
|
||||
let serverLists, serverFields, serverItems, serverItemValues;
|
||||
|
||||
if (lastSyncTimestamp === 0) {
|
||||
if (safeLastSyncTimestamp === 0) {
|
||||
// Include deleted rows on initial sync to propagate tombstones
|
||||
serverLists = await dbAdapter.queryAll('SELECT * FROM lists');
|
||||
serverFields = await dbAdapter.queryAll('SELECT * FROM fields');
|
||||
serverItems = await dbAdapter.queryAll('SELECT * FROM items');
|
||||
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values');
|
||||
} else {
|
||||
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
|
||||
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
|
||||
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
|
||||
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
|
||||
}
|
||||
|
||||
// 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
|
||||
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`);
|
||||
} 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`);
|
||||
|
||||
Reference in New Issue
Block a user