Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2592508cb | ||
|
|
09ae5309d4 | ||
|
|
dbab7b1934 | ||
|
|
a896ae8209 | ||
|
|
94382573d0 | ||
|
|
76c4a5ef96 | ||
|
|
c82b8aebca |
@@ -0,0 +1,37 @@
|
|||||||
|
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 }}
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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,7 +53,6 @@ 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,8 +110,7 @@ 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 (error: Exception) {
|
} catch (e: 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.GET
|
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
data class SyncRequest(
|
data class SyncRequest(
|
||||||
|
|||||||
@@ -47,13 +47,6 @@ 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(
|
||||||
@@ -61,7 +54,12 @@ interface FieldDao {
|
|||||||
timestamp: Long,
|
timestamp: Long,
|
||||||
) {
|
) {
|
||||||
reorderedFields.forEachIndexed { index, field ->
|
reorderedFields.forEachIndexed { index, field ->
|
||||||
updateFieldOrder(field.id, index, timestamp)
|
updateField(
|
||||||
|
field.copy(
|
||||||
|
order = index,
|
||||||
|
updatedAt = timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ enum class FieldType {
|
|||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
|
|
||||||
// Selection types
|
// Selection types
|
||||||
SELECT,
|
DROPDOWN,
|
||||||
MULTI_SELECT,
|
|
||||||
AUTOCOMPLETE,
|
AUTOCOMPLETE,
|
||||||
CHECKBOX,
|
CHECKBOX,
|
||||||
SWITCH,
|
SWITCH,
|
||||||
@@ -73,13 +72,11 @@ data class Field(
|
|||||||
) {
|
) {
|
||||||
fun getType(): FieldType {
|
fun getType(): FieldType {
|
||||||
val raw = fieldType.ifBlank { "TEXT" }.uppercase()
|
val raw = fieldType.ifBlank { "TEXT" }.uppercase()
|
||||||
val exact = runCatching { FieldType.valueOf(raw) }.getOrNull()
|
return try {
|
||||||
if (exact != null) {
|
FieldType.valueOf(raw)
|
||||||
return exact
|
} catch (e: Exception) {
|
||||||
}
|
|
||||||
// Handle legacy/synonym field types
|
// Handle legacy/synonym field types
|
||||||
return when (raw) {
|
when (raw) {
|
||||||
"DROPDOWN" -> FieldType.SELECT
|
|
||||||
"STRING" -> FieldType.TEXT
|
"STRING" -> FieldType.TEXT
|
||||||
"PRICE" -> FieldType.CURRENCY
|
"PRICE" -> FieldType.CURRENCY
|
||||||
"AMOUNT" -> FieldType.NUMBER
|
"AMOUNT" -> FieldType.NUMBER
|
||||||
@@ -87,9 +84,10 @@ data class Field(
|
|||||||
else -> FieldType.TEXT
|
else -> FieldType.TEXT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getSelectOptions(): List<String> =
|
fun getDropdownOptions(): List<String> =
|
||||||
if ((getType() == FieldType.SELECT || getType() == FieldType.MULTI_SELECT) && fieldOptions.isNotBlank()) {
|
if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) {
|
||||||
fieldOptions.split("|")
|
fieldOptions.split("|")
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
|
|||||||
+12
-11
@@ -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 _isFirstRunFlow = MutableStateFlow(isFirstRun())
|
private val _isFirstRun = MutableStateFlow(isFirstRun())
|
||||||
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRunFlow.asStateFlow()
|
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRun.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()
|
||||||
_isFirstRunFlow.value = isFirstRun
|
_isFirstRun.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 runCatching {
|
return try {
|
||||||
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,7 +206,9 @@ class PreferencesManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
map
|
map
|
||||||
}.getOrDefault(emptyMap())
|
} catch (e: Exception) {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setColumnWidths(
|
fun setColumnWidths(
|
||||||
@@ -227,7 +229,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 runCatching {
|
return try {
|
||||||
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()
|
||||||
@@ -243,7 +245,9 @@ class PreferencesManager(
|
|||||||
map[fieldId] = normalized
|
map[fieldId] = normalized
|
||||||
}
|
}
|
||||||
map
|
map
|
||||||
}.getOrDefault(emptyMap())
|
} catch (e: Exception) {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setColumnAlignments(
|
fun setColumnAlignments(
|
||||||
@@ -268,10 +272,7 @@ 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 =
|
val id = java.util.UUID.randomUUID().toString()
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -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
|
val network = cm.activeNetwork ?: return false
|
||||||
val caps = network?.let { cm.getNetworkCapabilities(it) }
|
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||||
val hasInternet = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
val isValidated = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true
|
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
val hasTransport =
|
val hasTransport =
|
||||||
caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true ||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||||
caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true ||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
|
||||||
caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||||
return hasInternet && isValidated && hasTransport
|
return hasInternet && isValidated && hasTransport
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-13
@@ -1,12 +1,8 @@
|
|||||||
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 {
|
||||||
@@ -38,15 +34,6 @@ 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())
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-14
@@ -2,6 +2,7 @@ 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
|
||||||
@@ -22,8 +23,7 @@ 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 =
|
job = CoroutineScope(Dispatchers.IO).launch {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
try {
|
try {
|
||||||
// Skip if password missing (auth disabled)
|
// Skip if password missing (auth disabled)
|
||||||
@@ -53,10 +53,7 @@ object NotificationPoller {
|
|||||||
delay(5_000)
|
delay(5_000)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
try {
|
try { Logger.w("NotifPoll", "Polling failed: ${e.message}") } catch (_: Exception) {}
|
||||||
Logger.w("NotifPoll", "Polling failed: ${e.message}")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
// brief backoff
|
// brief backoff
|
||||||
delay(1_500)
|
delay(1_500)
|
||||||
}
|
}
|
||||||
@@ -79,21 +76,17 @@ object NotificationPoller {
|
|||||||
) {
|
) {
|
||||||
// Respect notification switches by grouping semantics
|
// Respect notification switches by grouping semantics
|
||||||
when (ev.entityType.lowercase()) {
|
when (ev.entityType.lowercase()) {
|
||||||
"list" ->
|
"list" -> when (ev.eventType.lowercase()) {
|
||||||
when (ev.eventType.lowercase()) {
|
|
||||||
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName)
|
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName)
|
||||||
"updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(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)
|
"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" ->
|
"field", "item", "value" -> if (prefs.notifyListContentUpdated.value) {
|
||||||
if (prefs.notifyListContentUpdated.value) {
|
|
||||||
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
|
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
|
||||||
}
|
}
|
||||||
else ->
|
else -> when (ev.eventType.lowercase()) {
|
||||||
when (ev.eventType.lowercase()) {
|
"listcontentupdated" -> if (prefs.notifyListContentUpdated.value) {
|
||||||
"listcontentupdated" ->
|
|
||||||
if (prefs.notifyListContentUpdated.value) {
|
|
||||||
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
|
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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 runCatching {
|
return try {
|
||||||
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
|
||||||
}.getOrElse {
|
} catch (e: Exception) {
|
||||||
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 (ignored: Exception) {
|
} catch (e: Exception) {
|
||||||
ok = false
|
ok = false
|
||||||
}
|
}
|
||||||
delay(intervalMs)
|
delay(intervalMs)
|
||||||
|
|||||||
+39
-121
@@ -1,9 +1,4 @@
|
|||||||
@file:Suppress(
|
@file:Suppress("ktlint:standard:no-wildcard-imports")
|
||||||
"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
|
||||||
@@ -56,6 +51,7 @@ 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
|
||||||
@@ -230,7 +226,6 @@ 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,
|
||||||
@@ -240,7 +235,7 @@ fun ListDetailScreen(
|
|||||||
filterField,
|
filterField,
|
||||||
filterValue,
|
filterValue,
|
||||||
) {
|
) {
|
||||||
val transformedItems =
|
value =
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
transformItems(
|
transformItems(
|
||||||
items = stableItems,
|
items = stableItems,
|
||||||
@@ -251,7 +246,6 @@ fun ListDetailScreen(
|
|||||||
filterValue = filterValue,
|
filterValue = filterValue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
value = transformedItems
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val processedItems = transformed.processed
|
val processedItems = transformed.processed
|
||||||
@@ -556,8 +550,7 @@ fun ListDetailScreen(
|
|||||||
}
|
}
|
||||||
stableItems.isEmpty() -> {
|
stableItems.isEmpty() -> {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@@ -565,8 +558,7 @@ fun ListDetailScreen(
|
|||||||
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
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -647,8 +639,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, alignment ->
|
onUpdateField = { fieldId, name, fieldType, fieldOptions ->
|
||||||
viewModel.updateField(fieldId, name, fieldType, fieldOptions, alignment)
|
viewModel.updateField(fieldId, name, fieldType, fieldOptions)
|
||||||
},
|
},
|
||||||
onUpdateAlignment = { fieldId, alignment ->
|
onUpdateAlignment = { fieldId, alignment ->
|
||||||
viewModel.updateFieldAlignment(fieldId, alignment)
|
viewModel.updateFieldAlignment(fieldId, alignment)
|
||||||
@@ -812,8 +804,7 @@ 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.SELECT,
|
com.collabtable.app.data.model.FieldType.DROPDOWN,
|
||||||
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,
|
||||||
@@ -857,8 +848,7 @@ private fun fieldTypeToLabel(type: String): String =
|
|||||||
"NUMBER" -> "Number"
|
"NUMBER" -> "Number"
|
||||||
"CURRENCY", "PRICE" -> "Currency"
|
"CURRENCY", "PRICE" -> "Currency"
|
||||||
"PERCENTAGE" -> "Percentage"
|
"PERCENTAGE" -> "Percentage"
|
||||||
"SELECT", "Select" -> "Select"
|
"DROPDOWN" -> "Dropdown"
|
||||||
"MULTI_SELECT" -> "Multi-select"
|
|
||||||
"AUTOCOMPLETE" -> "Autocomplete"
|
"AUTOCOMPLETE" -> "Autocomplete"
|
||||||
"CHECKBOX" -> "Checkbox"
|
"CHECKBOX" -> "Checkbox"
|
||||||
"SWITCH" -> "Switch"
|
"SWITCH" -> "Switch"
|
||||||
@@ -1054,7 +1044,8 @@ 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
|
||||||
@@ -1111,9 +1102,7 @@ fun ItemRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
com.collabtable.app.data.model.FieldType.SELECT,
|
com.collabtable.app.data.model.FieldType.DROPDOWN -> {
|
||||||
com.collabtable.app.data.model.FieldType.MULTI_SELECT,
|
|
||||||
-> {
|
|
||||||
Text(
|
Text(
|
||||||
text = value?.value ?: "",
|
text = value?.value ?: "",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -1587,16 +1576,9 @@ fun AddFieldDialog(
|
|||||||
|
|
||||||
// Selection types
|
// Selection types
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Select") },
|
text = { Text("Dropdown") },
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedFieldType = "SELECT"
|
selectedFieldType = "DROPDOWN"
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Multi-select") },
|
|
||||||
onClick = {
|
|
||||||
selectedFieldType = "MULTI_SELECT"
|
|
||||||
expanded = false
|
expanded = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1743,14 +1725,14 @@ fun AddFieldDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dropdown-specific options
|
// Dropdown-specific options
|
||||||
if (selectedFieldType == "SELECT" || selectedFieldType == "MULTI_SELECT") {
|
if (selectedFieldType == "DROPDOWN") {
|
||||||
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 select choices separated by commas") },
|
supportingText = { Text("Enter dropdown choices separated by commas") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1788,7 +1770,7 @@ fun AddFieldDialog(
|
|||||||
val options =
|
val options =
|
||||||
when (normalizedType) {
|
when (normalizedType) {
|
||||||
"CURRENCY" -> currency.trim()
|
"CURRENCY" -> currency.trim()
|
||||||
"SELECT", "MULTI_SELECT" ->
|
"DROPDOWN" ->
|
||||||
dropdownOptions
|
dropdownOptions
|
||||||
.split(",")
|
.split(",")
|
||||||
.map { it.trim() }
|
.map { it.trim() }
|
||||||
@@ -1808,7 +1790,7 @@ fun AddFieldDialog(
|
|||||||
},
|
},
|
||||||
enabled =
|
enabled =
|
||||||
fieldName.isNotBlank() &&
|
fieldName.isNotBlank() &&
|
||||||
(selectedFieldType.uppercase() !in listOf("SELECT", "MULTI_SELECT", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
|
(selectedFieldType.uppercase() !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.add))
|
Text(stringResource(R.string.add))
|
||||||
}
|
}
|
||||||
@@ -1830,7 +1812,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.getSelectOptions().joinToString(", ")) }
|
var dropdownOptions by remember { mutableStateOf(field.getDropdownOptions().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
|
||||||
@@ -1920,16 +1902,9 @@ fun EditFieldDialog(
|
|||||||
|
|
||||||
// Selection types
|
// Selection types
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Select") },
|
text = { Text("Dropdown") },
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedFieldType = "SELECT"
|
selectedFieldType = "DROPDOWN"
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Multi-select") },
|
|
||||||
onClick = {
|
|
||||||
selectedFieldType = "MULTI_SELECT"
|
|
||||||
expanded = false
|
expanded = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2076,14 +2051,14 @@ fun EditFieldDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dropdown-specific options
|
// Dropdown-specific options
|
||||||
if (selectedFieldType == "SELECT" || selectedFieldType == "MULTI_SELECT") {
|
if (selectedFieldType == "DROPDOWN") {
|
||||||
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 select choices separated by commas") },
|
supportingText = { Text("Enter dropdown choices separated by commas") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2138,7 +2113,7 @@ fun EditFieldDialog(
|
|||||||
val options =
|
val options =
|
||||||
when (normalizedType) {
|
when (normalizedType) {
|
||||||
"CURRENCY", "PRICE" -> currency.trim()
|
"CURRENCY", "PRICE" -> currency.trim()
|
||||||
"SELECT", "MULTI_SELECT" ->
|
"DROPDOWN" ->
|
||||||
dropdownOptions
|
dropdownOptions
|
||||||
.split(",")
|
.split(",")
|
||||||
.map { it.trim() }
|
.map { it.trim() }
|
||||||
@@ -2164,7 +2139,7 @@ fun EditFieldDialog(
|
|||||||
}
|
}
|
||||||
prefs.setColumnAlignments(field.listId, current)
|
prefs.setColumnAlignments(field.listId, current)
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank() && (selectedFieldType !in listOf("SELECT", "MULTI_SELECT", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
|
enabled = name.isNotBlank() && (selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
|
||||||
) {
|
) {
|
||||||
Text("Update")
|
Text("Update")
|
||||||
}
|
}
|
||||||
@@ -2331,13 +2306,13 @@ fun FieldInput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
com.collabtable.app.data.model.FieldType.SELECT -> {
|
com.collabtable.app.data.model.FieldType.DROPDOWN -> {
|
||||||
val options = field.getSelectOptions()
|
val options = field.getDropdownOptions()
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var dropdownExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = dropdownExpanded,
|
||||||
onExpandedChange = { expanded = !expanded },
|
onExpandedChange = { dropdownExpanded = !dropdownExpanded },
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
@@ -2345,7 +2320,7 @@ fun FieldInput(
|
|||||||
readOnly = true,
|
readOnly = true,
|
||||||
label = { Text(field.name) },
|
label = { Text(field.name) },
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -2354,71 +2329,15 @@ fun FieldInput(
|
|||||||
)
|
)
|
||||||
|
|
||||||
ExposedDropdownMenu(
|
ExposedDropdownMenu(
|
||||||
expanded = expanded,
|
expanded = dropdownExpanded,
|
||||||
onDismissRequest = { expanded = false },
|
onDismissRequest = { dropdownExpanded = false },
|
||||||
) {
|
) {
|
||||||
options.forEach { option ->
|
options.forEach { option ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(option) },
|
text = { Text(option) },
|
||||||
onClick = {
|
onClick = {
|
||||||
onValueChange(option)
|
onValueChange(option)
|
||||||
expanded = false
|
dropdownExpanded = 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(", "))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2883,7 +2802,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, String) -> Unit,
|
onUpdateField: (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,
|
||||||
@@ -3073,7 +2992,8 @@ 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, selectedAlignment)
|
onUpdateField(field.id, name, fieldType, fieldOptions)
|
||||||
|
onUpdateAlignment(field.id, selectedAlignment)
|
||||||
fieldToEdit = null
|
fieldToEdit = null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -3150,10 +3070,8 @@ 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.SELECT ->
|
com.collabtable.app.data.model.FieldType.DROPDOWN ->
|
||||||
"Select (${field.getSelectOptions().size} options)"
|
"Dropdown (${field.getDropdownOptions().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)"
|
||||||
|
|
||||||
|
|||||||
+7
-8
@@ -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 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
|
||||||
@@ -121,6 +123,11 @@ 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 */ }
|
||||||
|
|
||||||
@@ -217,25 +224,17 @@ 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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-21
@@ -3,9 +3,7 @@
|
|||||||
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
|
||||||
@@ -31,6 +29,9 @@ 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
|
||||||
@@ -59,7 +60,6 @@ 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,7 +72,6 @@ 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(
|
||||||
@@ -277,7 +276,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 = { _, table -> table.id }) { _, list ->
|
itemsIndexed(working, key = { _, it -> it.id }) { _, list ->
|
||||||
ReorderableItem(reorderState, key = list.id) { _ ->
|
ReorderableItem(reorderState, key = list.id) { _ ->
|
||||||
Box(modifier = Modifier.animateItemPlacement()) {
|
Box(modifier = Modifier.animateItemPlacement()) {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -350,35 +349,28 @@ 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 =
|
val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
// Take persistable permission for potential reuse during this session
|
// Take persistable permission for potential reuse during this session
|
||||||
try {
|
try {
|
||||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) { }
|
||||||
}
|
|
||||||
exportTargetUri = uri
|
exportTargetUri = uri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ExportListDialog(
|
ExportListDialog(
|
||||||
list = list,
|
list = list,
|
||||||
selectedDirectoryLabel =
|
selectedDirectoryLabel = exportTargetUri?.let { uri ->
|
||||||
exportTargetUri?.let { uri ->
|
|
||||||
DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)"
|
DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)"
|
||||||
} ?: "App storage (default)",
|
} ?: "App storage (default)",
|
||||||
onSelectDirectory = { folderPicker.launch(null) },
|
onSelectDirectory = { folderPicker.launch(null) },
|
||||||
onDismiss = {
|
onDismiss = { listToExport = null; exportTargetUri = null },
|
||||||
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
|
result.onSuccess { path ->
|
||||||
.onSuccess { path ->
|
|
||||||
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
|
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
|
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
|
||||||
@@ -508,6 +500,7 @@ fun CreateListDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -619,10 +612,7 @@ 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 = {
|
onClick = { selectedFormat = "CSV"; expanded = false },
|
||||||
selectedFormat = "CSV"
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-15
@@ -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 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
|
||||||
@@ -21,10 +19,12 @@ 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.Locale
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
class ListsViewModel(
|
class ListsViewModel(
|
||||||
private val database: CollabTableDatabase,
|
private val database: CollabTableDatabase,
|
||||||
@@ -233,7 +233,6 @@ 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,
|
||||||
@@ -248,8 +247,7 @@ class ListsViewModel(
|
|||||||
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 =
|
val rows = items.map { iwv ->
|
||||||
items.map { iwv ->
|
|
||||||
val valueMap = iwv.values.associateBy { it.fieldId }
|
val valueMap = iwv.values.associateBy { it.fieldId }
|
||||||
fieldIdOrder.joinToString(",") { fid ->
|
fieldIdOrder.joinToString(",") { fid ->
|
||||||
val raw = valueMap[fid]?.value ?: ""
|
val raw = valueMap[fid]?.value ?: ""
|
||||||
@@ -257,8 +255,7 @@ class ListsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val csv =
|
val csv = buildString {
|
||||||
buildString {
|
|
||||||
append(headers.joinToString(","))
|
append(headers.joinToString(","))
|
||||||
append('\n')
|
append('\n')
|
||||||
rows.forEachIndexed { idx, row ->
|
rows.forEachIndexed { idx, row ->
|
||||||
@@ -267,14 +264,9 @@ class ListsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val safeBase =
|
val safeBase = listName.lowercase(Locale.US).replace("[^a-z0-9]+".toRegex(), "_").trim('_').ifBlank { "table" }
|
||||||
listName
|
|
||||||
.lowercase(Locale.US)
|
|
||||||
.replace("[^a-z0-9]+".toRegex(), "_")
|
|
||||||
.trim('_')
|
|
||||||
.ifBlank { "table" }
|
|
||||||
val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||||
val fileName = "${safeBase}_$ts.csv"
|
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()) {
|
||||||
|
|||||||
-2
@@ -1,5 +1,3 @@
|
|||||||
@file:Suppress("SwallowedException")
|
|
||||||
|
|
||||||
package com.collabtable.app.ui.screens
|
package com.collabtable.app.ui.screens
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|||||||
+1
-3
@@ -64,7 +64,6 @@ 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(
|
||||||
@@ -539,8 +538,7 @@ 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(
|
Text(stringResource(R.string.leave_server_dialog_body),
|
||||||
stringResource(R.string.leave_server_dialog_body),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
|||||||
@@ -84,13 +84,8 @@ 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 (isDuplicateMessage && withinDedupeWindow) {
|
if (last != null && last.level == level && last.tag == tag && last.message == message && (now - last.timestamp) <= DEDUPE_WINDOW_MS) {
|
||||||
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)
|
||||||
|
|||||||
+1
-3
@@ -7,7 +7,6 @@ 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,
|
||||||
@@ -29,8 +28,7 @@ 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 (error: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.w("ListNotifyWorker", "Background notification polling failed: ${error.message}")
|
|
||||||
Result.retry()
|
Result.retry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,19 +33,3 @@ 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
|
|
||||||
|
|||||||
@@ -230,10 +230,6 @@ 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.
@@ -14,11 +14,6 @@ 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
|
||||||
|
|||||||
Generated
+4
-4
@@ -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.5.4"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cspotcode/source-map-support": {
|
"node_modules/@cspotcode/source-map-support": {
|
||||||
@@ -3607,9 +3607,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.5.4",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|||||||
@@ -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.5.4",
|
"typescript": "^5.3.3",
|
||||||
"ts-node-dev": "^2.0.0"
|
"ts-node-dev": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,7 +296,33 @@ 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);
|
||||||
|
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();
|
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 };
|
||||||
|
|||||||
@@ -16,39 +16,6 @@ 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());
|
||||||
@@ -77,7 +44,7 @@ app.use('/', webRoutes);
|
|||||||
// Initialize database and start server
|
// Initialize database and start server
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await initializeDatabaseWithRetry();
|
await initializeDatabase();
|
||||||
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');
|
||||||
|
|||||||
@@ -1,33 +1,6 @@
|
|||||||
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;
|
||||||
@@ -45,13 +18,12 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
|
|||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
if (!isCommonProbePath(req.path) && shouldLogAuthWarning('missingHeader', req)) {
|
// Log diagnostic info to help trace intermittent 401s
|
||||||
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'
|
||||||
@@ -61,7 +33,6 @@ 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') {
|
||||||
if (shouldLogAuthWarning('invalidHeaderFormat', req)) {
|
|
||||||
console.warn('[AUTH] Invalid authorization header format', {
|
console.warn('[AUTH] Invalid authorization header format', {
|
||||||
path: req.path,
|
path: req.path,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
@@ -69,7 +40,6 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
|
|||||||
// Do not log full header to avoid leaking secrets; include prefix only
|
// Do not log full header to avoid leaking secrets; include prefix only
|
||||||
headerPreview: authHeader.substring(0, Math.min(authHeader.length, 20)) + '...'
|
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>'
|
||||||
@@ -81,15 +51,13 @@ 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
|
||||||
if (shouldLogAuthWarning('invalidPassword', req)) {
|
const safePreview = providedPassword ? `${providedPassword.substring(0, 2)}…(${providedPassword.length})` : 'null';
|
||||||
const safePreview = providedPassword ? `${providedPassword.substring(0, 2)}..(${providedPassword.length})` : 'null';
|
|
||||||
console.warn('[AUTH] Invalid password', {
|
console.warn('[AUTH] Invalid password', {
|
||||||
path: req.path,
|
path: req.path,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
providedPreview: safePreview,
|
providedPreview: safePreview,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
message: 'Invalid password'
|
message: 'Invalid password'
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ 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 (notificationError) {
|
} catch {}
|
||||||
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' });
|
||||||
@@ -103,9 +101,7 @@ 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 (notificationError) {
|
} catch {}
|
||||||
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' });
|
||||||
|
|||||||
@@ -12,36 +12,6 @@ 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,
|
||||||
@@ -85,7 +55,7 @@ setInterval(() => {
|
|||||||
lastReset: Date.now()
|
lastReset: Date.now()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, 60000).unref?.(); // Changed from 30s to 60s
|
}, 60000); // 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
|
||||||
@@ -96,23 +66,15 @@ 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 {
|
const { lastSyncTimestamp, lists, fields, items, itemValues }: SyncRequest = req.body;
|
||||||
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;
|
const incomingLists = lists?.length || 0;
|
||||||
const incomingFields = fields.length;
|
const incomingFields = fields?.length || 0;
|
||||||
const incomingItems = items.length;
|
const incomingItems = items?.length || 0;
|
||||||
const incomingValues = itemValues.length;
|
const incomingValues = itemValues?.length || 0;
|
||||||
|
|
||||||
syncStats.listsReceived += incomingLists;
|
syncStats.listsReceived += incomingLists;
|
||||||
syncStats.fieldsReceived += incomingFields;
|
syncStats.fieldsReceived += incomingFields;
|
||||||
@@ -121,7 +83,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 = safeLastSyncTimestamp === 0;
|
const isInitialSync = lastSyncTimestamp === 0;
|
||||||
|
|
||||||
if (hasIncomingData) {
|
if (hasIncomingData) {
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
@@ -130,7 +92,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' : (safeLastSyncTimestamp === 0 ? 'Created' : 'Updated');
|
const action = list.isDeleted ? 'Deleted' : (lastSyncTimestamp === 0 ? 'Created' : 'Updated');
|
||||||
console.log(` ${action}: "${list.name}"`);
|
console.log(` ${action}: "${list.name}"`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -155,28 +117,25 @@ 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,
|
||||||
listTs.createdAt,
|
list.createdAt,
|
||||||
listTs.updatedAt,
|
list.updatedAt,
|
||||||
list.isDeleted ? 1 : 0
|
list.isDeleted ? 1 : 0
|
||||||
]);
|
]);
|
||||||
// enqueue list-level notifications
|
// enqueue list-level notifications
|
||||||
try {
|
try {
|
||||||
const ev = list.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
|
const ev = list.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated');
|
||||||
await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, listTs.updatedAt);
|
await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, list.updatedAt);
|
||||||
} catch (notificationError) {
|
} catch {}
|
||||||
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 = ?', [listTs.updatedAt, list.id]);
|
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 = ?', [listTs.updatedAt, list.id]);
|
await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.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));
|
||||||
@@ -187,7 +146,6 @@ 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,
|
||||||
@@ -196,16 +154,14 @@ router.post('/sync', async (req: Request, res: Response) => {
|
|||||||
(field.alignment ?? 'start'),
|
(field.alignment ?? 'start'),
|
||||||
field.listId,
|
field.listId,
|
||||||
field.order,
|
field.order,
|
||||||
fieldTs.createdAt,
|
field.createdAt,
|
||||||
fieldTs.updatedAt,
|
field.updatedAt,
|
||||||
field.isDeleted ? 1 : 0
|
field.isDeleted ? 1 : 0
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
const ev = field.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
|
const ev = field.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated');
|
||||||
await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, fieldTs.updatedAt);
|
await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, field.updatedAt);
|
||||||
} catch (notificationError) {
|
} catch {}
|
||||||
void notificationError;
|
|
||||||
}
|
|
||||||
if (field.isDeleted) {
|
if (field.isDeleted) {
|
||||||
// Remove item_values for this field
|
// Remove item_values for this field
|
||||||
try {
|
try {
|
||||||
@@ -219,7 +175,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 = ?', [fieldTs.updatedAt, field.listId]);
|
await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [field.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);
|
||||||
}
|
}
|
||||||
@@ -232,20 +188,17 @@ 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,
|
||||||
itemTs.createdAt,
|
item.createdAt,
|
||||||
itemTs.updatedAt,
|
item.updatedAt,
|
||||||
item.isDeleted ? 1 : 0
|
item.isDeleted ? 1 : 0
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
const ev = item.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
|
const ev = item.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated');
|
||||||
await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, itemTs.updatedAt);
|
await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, item.updatedAt);
|
||||||
} catch (notificationError) {
|
} catch {}
|
||||||
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 {
|
||||||
@@ -285,7 +238,6 @@ 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) {
|
||||||
@@ -300,7 +252,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 = valueUpdatedAt;
|
const ts = value.updatedAt ?? Date.now();
|
||||||
try {
|
try {
|
||||||
await tx.execute(UPSERT_ITEM, [
|
await tx.execute(UPSERT_ITEM, [
|
||||||
value.itemId,
|
value.itemId,
|
||||||
@@ -322,17 +274,15 @@ router.post('/sync', async (req: Request, res: Response) => {
|
|||||||
value.itemId,
|
value.itemId,
|
||||||
value.fieldId,
|
value.fieldId,
|
||||||
value.value,
|
value.value,
|
||||||
valueUpdatedAt
|
value.updatedAt
|
||||||
]);
|
]);
|
||||||
// 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, valueUpdatedAt);
|
await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, value.updatedAt);
|
||||||
}
|
|
||||||
} catch (notificationError) {
|
|
||||||
void notificationError;
|
|
||||||
}
|
}
|
||||||
|
} catch {}
|
||||||
} 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') {
|
||||||
@@ -359,17 +309,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 (safeLastSyncTimestamp === 0) {
|
if (lastSyncTimestamp === 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 >= ?', [safeLastSyncTimestamp]);
|
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||||
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
|
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||||
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
|
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||||
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
|
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize output for clients (camelCase keys, ms timestamps, boolean isDeleted)
|
// Normalize output for clients (camelCase keys, ms timestamps, boolean isDeleted)
|
||||||
@@ -433,7 +383,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 && hasIncomingData) {
|
if (hasOutgoingData && !isInitialSync) {
|
||||||
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`);
|
||||||
|
|||||||
Reference in New Issue
Block a user