Refactor database access to use a unified dbAdapter for SQLite and PostgreSQL
- Replaced direct database calls with dbAdapter methods in fieldRoutes, itemRoutes, listRoutes, syncRoutes, and webRoutes. - Implemented a new db.ts file to handle database interactions for both SQLite and PostgreSQL. - Added WebSocket support for real-time synchronization in WebSocketSyncClient and ws.ts. - Updated TypeScript configuration to include WebSocket types. - Created a new WebSocket server for handling sync requests and responses. - Ensured compatibility with both SQLite and PostgreSQL by using parameterized queries.
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
# CollabTable Android App
|
# CollabTable Android App
|
||||||
|
|
||||||
A collaborative list management Android application built with Jetpack Compose and Material 3.
|
A collaborative table management Android application built with Jetpack Compose and Material 3.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Create and manage multiple lists
|
- Create and manage multiple tables
|
||||||
- Add custom fields to lists (name, link, price, category, etc.)
|
- Add custom fields to tables (name, link, price, category, etc.)
|
||||||
- Add items with values for each field
|
- Add items with values for each field
|
||||||
- Beautiful Material 3 design with dynamic colors
|
- Beautiful Material 3 design with dynamic colors
|
||||||
- Local Room database for offline support
|
- Local Room database for offline support
|
||||||
@@ -36,7 +36,7 @@ A collaborative list management Android application built with Jetpack Compose a
|
|||||||
|
|
||||||
The app includes a Settings screen where you can configure the server URL:
|
The app includes a Settings screen where you can configure the server URL:
|
||||||
|
|
||||||
1. Tap the Settings icon (⚙️) in the top bar of the Lists screen
|
1. Tap the Settings icon (⚙️) in the top bar of the Tables screen
|
||||||
2. Enter your server URL
|
2. Enter your server URL
|
||||||
3. Tap "Save"
|
3. Tap "Save"
|
||||||
|
|
||||||
@@ -75,13 +75,13 @@ app/src/main/java/com/collabtable/app/
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Creating a List
|
### Creating a Table
|
||||||
1. Tap the + button on the main screen
|
1. Tap the + button on the main screen
|
||||||
2. Enter a list name
|
2. Enter a table name
|
||||||
3. Tap "Save"
|
3. Tap "Save"
|
||||||
|
|
||||||
### Adding Fields
|
### Adding Fields
|
||||||
1. Open a list
|
1. Open a table
|
||||||
2. Tap the + icon in the top bar
|
2. Tap the + icon in the top bar
|
||||||
3. Enter a field name (e.g., "Name", "Price", "Category")
|
3. Enter a field name (e.g., "Name", "Price", "Category")
|
||||||
4. Tap "Add"
|
4. Tap "Add"
|
||||||
@@ -92,7 +92,7 @@ app/src/main/java/com/collabtable/app/
|
|||||||
3. Values are saved automatically as you type
|
3. Values are saved automatically as you type
|
||||||
|
|
||||||
### Deleting
|
### Deleting
|
||||||
- Tap the delete icon next to any list, field, or item
|
- Tap the delete icon next to any table, field, or item
|
||||||
- Confirm the deletion
|
- Confirm the deletion
|
||||||
|
|
||||||
## Synchronization
|
## Synchronization
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.collabtable.app.data.preferences.PreferencesManager
|
||||||
import com.collabtable.app.ui.navigation.AppNavigation
|
import com.collabtable.app.ui.navigation.AppNavigation
|
||||||
import com.collabtable.app.ui.theme.CollabTableTheme
|
import com.collabtable.app.ui.theme.CollabTableTheme
|
||||||
|
|
||||||
@@ -14,7 +17,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
CollabTableTheme {
|
val prefs = PreferencesManager.getInstance(this)
|
||||||
|
val themeMode by prefs.themeMode.collectAsState()
|
||||||
|
val dynamicColor by prefs.dynamicColor.collectAsState()
|
||||||
|
val amoledDark by prefs.amoledDark.collectAsState()
|
||||||
|
|
||||||
|
val darkTheme = when (themeMode) {
|
||||||
|
PreferencesManager.THEME_MODE_LIGHT -> false
|
||||||
|
PreferencesManager.THEME_MODE_DARK -> true
|
||||||
|
else -> androidx.compose.foundation.isSystemInDarkTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
CollabTableTheme(darkTheme = darkTheme, dynamicColor = dynamicColor, amoledDark = amoledDark) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
|
|||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package com.collabtable.app.data.api
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.collabtable.app.data.preferences.PreferencesManager
|
||||||
|
import com.collabtable.app.utils.Logger
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
private data class MessageEnvelope(
|
||||||
|
val id: String? = null,
|
||||||
|
val type: String,
|
||||||
|
val payload: Any? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
object WebSocketSyncClient {
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
private val logging = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BASIC
|
||||||
|
}
|
||||||
|
|
||||||
|
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(logging)
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun buildWebSocketUrl(httpApiBaseUrl: String): String {
|
||||||
|
// Expecting something like: http://host:port/api/
|
||||||
|
var url = httpApiBaseUrl
|
||||||
|
// Ensure trailing slash removed for manipulation
|
||||||
|
if (url.endsWith("/")) url = url.dropLast(1)
|
||||||
|
|
||||||
|
// Replace scheme
|
||||||
|
url = when {
|
||||||
|
url.startsWith("https://") -> "wss://" + url.removePrefix("https://")
|
||||||
|
url.startsWith("http://") -> "ws://" + url.removePrefix("http://")
|
||||||
|
else -> "ws://$url" // best effort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace trailing /api with /api/ws
|
||||||
|
return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sync(context: Context, request: SyncRequest): Result<SyncResponse> = withContext(Dispatchers.IO) {
|
||||||
|
val prefs = PreferencesManager.getInstance(context)
|
||||||
|
val baseUrl = prefs.getServerUrl()
|
||||||
|
val password = prefs.getServerPassword()
|
||||||
|
val wsUrl = buildWebSocketUrl(baseUrl)
|
||||||
|
val messageId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<Result<SyncResponse>>()
|
||||||
|
var socket: WebSocket? = null
|
||||||
|
|
||||||
|
val httpRequestBuilder = Request.Builder().url(wsUrl)
|
||||||
|
if (!password.isNullOrBlank()) {
|
||||||
|
httpRequestBuilder.addHeader("Authorization", "Bearer $password")
|
||||||
|
}
|
||||||
|
val httpRequest = httpRequestBuilder.build()
|
||||||
|
|
||||||
|
val listener = object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
// Send sync message when opened
|
||||||
|
val envelope = MessageEnvelope(
|
||||||
|
id = messageId,
|
||||||
|
type = "sync",
|
||||||
|
payload = request
|
||||||
|
)
|
||||||
|
val json = gson.toJson(envelope)
|
||||||
|
webSocket.send(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
try {
|
||||||
|
val root = gson.fromJson(text, JsonObject::class.java)
|
||||||
|
val type = root.get("type")?.asString
|
||||||
|
val id = root.get("id")?.asString
|
||||||
|
if (type == "syncResponse" && id == messageId) {
|
||||||
|
val payload = root.getAsJsonObject("payload")
|
||||||
|
val resp = gson.fromJson(payload, SyncResponse::class.java)
|
||||||
|
if (!deferred.isCompleted) {
|
||||||
|
deferred.complete(Result.success(resp))
|
||||||
|
webSocket.close(1000, "done")
|
||||||
|
}
|
||||||
|
} else if (type == "error" && !deferred.isCompleted) {
|
||||||
|
val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error"
|
||||||
|
deferred.complete(Result.failure(Exception(msg)))
|
||||||
|
webSocket.close(1002, "error")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (!deferred.isCompleted) {
|
||||||
|
deferred.complete(Result.failure(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
// 1008 indicates policy violation, used here for Unauthorized
|
||||||
|
if (!deferred.isCompleted) {
|
||||||
|
if (code == 1008) {
|
||||||
|
deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)")))
|
||||||
|
} else {
|
||||||
|
deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
if (!deferred.isCompleted) {
|
||||||
|
deferred.complete(Result.failure(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket = client.newWebSocket(httpRequest, listener)
|
||||||
|
// Wait up to 15s for response
|
||||||
|
val result = withTimeout(15_000) { deferred.await() }
|
||||||
|
return@withContext result
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w("WS", "Falling back to HTTP sync: ${e.message}")
|
||||||
|
return@withContext Result.failure(e)
|
||||||
|
} finally {
|
||||||
|
// OkHttp cleans up sockets automatically when no references remain
|
||||||
|
// but we try to close if still open
|
||||||
|
socket?.close(1000, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -13,6 +13,15 @@ class PreferencesManager(context: Context) {
|
|||||||
private val _serverUrl = MutableStateFlow(getServerUrl())
|
private val _serverUrl = MutableStateFlow(getServerUrl())
|
||||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||||
|
|
||||||
|
private val _themeMode = MutableStateFlow(getThemeMode())
|
||||||
|
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||||
|
|
||||||
|
private val _dynamicColor = MutableStateFlow(isDynamicColorEnabled())
|
||||||
|
val dynamicColor: StateFlow<Boolean> = _dynamicColor.asStateFlow()
|
||||||
|
|
||||||
|
private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled())
|
||||||
|
val amoledDark: StateFlow<Boolean> = _amoledDark.asStateFlow()
|
||||||
|
|
||||||
fun getServerUrl(): String {
|
fun getServerUrl(): String {
|
||||||
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
|
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
|
||||||
}
|
}
|
||||||
@@ -64,12 +73,50 @@ class PreferencesManager(context: Context) {
|
|||||||
_serverUrl.value = getServerUrl()
|
_serverUrl.value = getServerUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme settings
|
||||||
|
fun getThemeMode(): String {
|
||||||
|
return prefs.getString(KEY_THEME_MODE, THEME_MODE_SYSTEM) ?: THEME_MODE_SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setThemeMode(mode: String) {
|
||||||
|
val normalized = when (mode.lowercase()) {
|
||||||
|
THEME_MODE_LIGHT, THEME_MODE_DARK, THEME_MODE_SYSTEM -> mode.lowercase()
|
||||||
|
else -> THEME_MODE_SYSTEM
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_THEME_MODE, normalized).apply()
|
||||||
|
_themeMode.value = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isDynamicColorEnabled(): Boolean {
|
||||||
|
return prefs.getBoolean(KEY_DYNAMIC_COLOR, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDynamicColorEnabled(enabled: Boolean) {
|
||||||
|
prefs.edit().putBoolean(KEY_DYNAMIC_COLOR, enabled).apply()
|
||||||
|
_dynamicColor.value = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAmoledDarkEnabled(): Boolean {
|
||||||
|
return prefs.getBoolean(KEY_AMOLED_DARK, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAmoledDarkEnabled(enabled: Boolean) {
|
||||||
|
prefs.edit().putBoolean(KEY_AMOLED_DARK, enabled).apply()
|
||||||
|
_amoledDark.value = enabled
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_SERVER_URL = "server_url"
|
private const val KEY_SERVER_URL = "server_url"
|
||||||
private const val KEY_FIRST_RUN = "first_run"
|
private const val KEY_FIRST_RUN = "first_run"
|
||||||
private const val KEY_SERVER_PASSWORD = "server_password"
|
private const val KEY_SERVER_PASSWORD = "server_password"
|
||||||
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
|
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
|
||||||
|
private const val KEY_THEME_MODE = "theme_mode" // system|light|dark
|
||||||
|
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
|
||||||
|
private const val KEY_AMOLED_DARK = "amoled_dark"
|
||||||
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
|
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
|
||||||
|
const val THEME_MODE_SYSTEM = "system"
|
||||||
|
const val THEME_MODE_LIGHT = "light"
|
||||||
|
const val THEME_MODE_DARK = "dark"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var instance: PreferencesManager? = null
|
private var instance: PreferencesManager? = null
|
||||||
|
|||||||
+22
-14
@@ -2,6 +2,7 @@ package com.collabtable.app.data.repository
|
|||||||
|
|
||||||
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.WebSocketSyncClient
|
||||||
import com.collabtable.app.data.api.SyncRequest
|
import com.collabtable.app.data.api.SyncRequest
|
||||||
import com.collabtable.app.data.database.CollabTableDatabase
|
import com.collabtable.app.data.database.CollabTableDatabase
|
||||||
import com.collabtable.app.utils.Logger
|
import com.collabtable.app.utils.Logger
|
||||||
@@ -55,11 +56,24 @@ class SyncRepository(context: Context) {
|
|||||||
itemValues = localValues
|
itemValues = localValues
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Try WebSocket first; on failure, fall back to HTTP
|
||||||
|
val wsResult = WebSocketSyncClient.sync(appContext, syncRequest)
|
||||||
|
val syncResponse = if (wsResult.isSuccess) {
|
||||||
|
wsResult.getOrThrow()
|
||||||
|
} else {
|
||||||
val response = api.sync(syncRequest)
|
val response = api.sync(syncRequest)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
if (response.isSuccessful) {
|
if (response.code() == 401) {
|
||||||
val syncResponse = response.body()!!
|
// Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error.
|
||||||
|
Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.")
|
||||||
|
setLastSyncTimestamp(0)
|
||||||
|
return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password."))
|
||||||
|
}
|
||||||
|
Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}")
|
||||||
|
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
|
||||||
|
}
|
||||||
|
response.body()!!
|
||||||
|
}
|
||||||
// Only log receive when there are actual server changes
|
// Only log receive when there are actual server changes
|
||||||
val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size
|
val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size
|
||||||
if (inTotal > 0) {
|
if (inTotal > 0) {
|
||||||
@@ -120,17 +134,11 @@ class SyncRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return@withContext Result.success(Unit)
|
return@withContext Result.success(Unit)
|
||||||
} else {
|
|
||||||
if (response.code() == 401) {
|
|
||||||
// Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error.
|
|
||||||
Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.")
|
|
||||||
setLastSyncTimestamp(0)
|
|
||||||
return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password."))
|
|
||||||
}
|
|
||||||
Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}")
|
|
||||||
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// If WS indicated unauthorized, align behavior with HTTP path
|
||||||
|
if (e.message?.contains("Unauthorized") == true) {
|
||||||
|
setLastSyncTimestamp(0)
|
||||||
|
}
|
||||||
Logger.e("Sync", "[ERROR] Sync error: ${e.message}")
|
Logger.e("Sync", "[ERROR] Sync error: ${e.message}")
|
||||||
return@withContext Result.failure(e)
|
return@withContext Result.failure(e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ fun ListsScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Tap + to create your first list",
|
text = "Tap + to create your first table",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -268,7 +268,7 @@ private fun RenameListDialog(
|
|||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Rename List") },
|
title = { Text("Rename Table") },
|
||||||
text = {
|
text = {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = listName,
|
value = listName,
|
||||||
|
|||||||
+5
-5
@@ -54,13 +54,13 @@ class ListsViewModel(
|
|||||||
private suspend fun performSync() {
|
private suspend fun performSync() {
|
||||||
val result = syncRepository.performSync()
|
val result = syncRepository.performSync()
|
||||||
result.onFailure { error ->
|
result.onFailure { error ->
|
||||||
Logger.e("Lists", "❌ Sync failed: ${error.message}")
|
Logger.e("Tables", "❌ Sync failed: ${error.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createList(name: String) {
|
fun createList(name: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
Logger.i("Lists", "➕ Creating list: \"$name\"")
|
Logger.i("Tables", "➕ Creating table: \"$name\"")
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
val newList = CollabList(
|
val newList = CollabList(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
@@ -78,7 +78,7 @@ class ListsViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val list = database.listDao().getListById(listId)
|
val list = database.listDao().getListById(listId)
|
||||||
if (list != null && newName.isNotBlank()) {
|
if (list != null && newName.isNotBlank()) {
|
||||||
Logger.i("Lists", "✏️ Renaming: \"${list.name}\" → \"$newName\"")
|
Logger.i("Tables", "✏️ Renaming table: \"${list.name}\" → \"$newName\"")
|
||||||
database.listDao().updateList(
|
database.listDao().updateList(
|
||||||
list.copy(
|
list.copy(
|
||||||
name = newName.trim(),
|
name = newName.trim(),
|
||||||
@@ -95,7 +95,7 @@ class ListsViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val list = database.listDao().getListById(listId)
|
val list = database.listDao().getListById(listId)
|
||||||
if (list != null) {
|
if (list != null) {
|
||||||
Logger.i("Lists", "🗑️ Deleting list: \"${list.name}\"")
|
Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"")
|
||||||
database.listDao().softDeleteList(listId, System.currentTimeMillis())
|
database.listDao().softDeleteList(listId, System.currentTimeMillis())
|
||||||
// Sync immediately after deleting
|
// Sync immediately after deleting
|
||||||
performSync()
|
performSync()
|
||||||
@@ -105,7 +105,7 @@ class ListsViewModel(
|
|||||||
|
|
||||||
fun manualSync() {
|
fun manualSync() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
Logger.i("Lists", "🔄 Manual sync requested")
|
Logger.i("Tables", "🔄 Manual sync requested")
|
||||||
_isLoading.value = true
|
_isLoading.value = true
|
||||||
performSync()
|
performSync()
|
||||||
_isLoading.value = false
|
_isLoading.value = false
|
||||||
|
|||||||
+9
-88
@@ -37,7 +37,6 @@ fun ServerSetupScreen(
|
|||||||
|
|
||||||
LaunchedEffect(validationResult) {
|
LaunchedEffect(validationResult) {
|
||||||
if (validationResult == true) {
|
if (validationResult == true) {
|
||||||
// Validation successful, navigate to main screen
|
|
||||||
onSetupComplete()
|
onSetupComplete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,34 +56,16 @@ fun ServerSetupScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(24.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
// Welcome text
|
|
||||||
Text(
|
|
||||||
text = "Welcome to CollabTable!",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Please configure your server connection to get started.",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Server URL input
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = serverUrl,
|
value = serverUrl,
|
||||||
onValueChange = { serverUrl = it },
|
onValueChange = { serverUrl = it },
|
||||||
label = { Text("Server URL") },
|
label = { Text("Server Hostname") },
|
||||||
placeholder = { Text("example.com or 10.0.2.2:3000") },
|
placeholder = { Text("example.com or 10.0.2.2:3000") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = !isValidating,
|
enabled = !isValidating,
|
||||||
@@ -95,7 +76,7 @@ fun ServerSetupScreen(
|
|||||||
text = validationError ?: "",
|
text = validationError ?: "",
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
else -> Text("Enter hostname with optional port (default: 80/443)")
|
else -> Text("Hostname only. Port optional (80/443 default). No http://, no /api/")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
@@ -118,7 +99,6 @@ fun ServerSetupScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server Password input
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = serverPassword,
|
value = serverPassword,
|
||||||
onValueChange = { serverPassword = it },
|
onValueChange = { serverPassword = it },
|
||||||
@@ -136,16 +116,11 @@ fun ServerSetupScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
supportingText = {
|
supportingText = { Text("Server password for authentication") }
|
||||||
Text("Password required for server authentication")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validate and continue button
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = { viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) },
|
||||||
viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim())
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating
|
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating
|
||||||
) {
|
) {
|
||||||
@@ -158,66 +133,12 @@ fun ServerSetupScreen(
|
|||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Validating...")
|
Text("Validating...")
|
||||||
} else {
|
} else {
|
||||||
Text("Validate and Continue")
|
Text("Connect")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// Tips card
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "Connection Tips:",
|
text = "Tip: Emulator host 10.0.2.2:3000 · Physical device uses your PC IP",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "• For Android Emulator: 10.0.2.2:3000",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "• For Physical Device: YOUR_IP:3000",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "• For domains: example.com (uses port 80/443)",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "• Port is optional, defaults to 80 (HTTP) or 443 (HTTPS)",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "• Just enter hostname:port, no need for http:// or /api/",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "• Make sure the server is running and accessible",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
// Note about changing later
|
|
||||||
Text(
|
|
||||||
text = "You can change this URL later in Settings",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
|
|||||||
+110
-14
@@ -15,6 +15,12 @@ import com.collabtable.app.data.api.ApiClient
|
|||||||
import com.collabtable.app.data.preferences.PreferencesManager
|
import com.collabtable.app.data.preferences.PreferencesManager
|
||||||
import com.collabtable.app.data.repository.SyncRepository
|
import com.collabtable.app.data.repository.SyncRepository
|
||||||
import com.collabtable.app.utils.Logger
|
import com.collabtable.app.utils.Logger
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material.icons.filled.DarkMode
|
||||||
|
import androidx.compose.material.icons.filled.LightMode
|
||||||
|
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -31,6 +37,10 @@ fun SettingsScreen(
|
|||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
val serverUrl = preferencesManager.getServerUrl()
|
val serverUrl = preferencesManager.getServerUrl()
|
||||||
|
val themeMode by preferencesManager.themeMode.collectAsState()
|
||||||
|
val dynamicColor by preferencesManager.dynamicColor.collectAsState()
|
||||||
|
val amoledDark by preferencesManager.amoledDark.collectAsState()
|
||||||
|
val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
|
||||||
var showLeaveDialog by remember { mutableStateOf(false) }
|
var showLeaveDialog by remember { mutableStateOf(false) }
|
||||||
var isLeaving by remember { mutableStateOf(false) }
|
var isLeaving by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -69,17 +79,17 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Connected to:",
|
text = "Connected to",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = serverUrl,
|
text = displayUrl,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -87,6 +97,70 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Appearance",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
// Theme mode selector
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = themeMode == PreferencesManager.THEME_MODE_SYSTEM,
|
||||||
|
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_SYSTEM) },
|
||||||
|
label = { Text("System") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = themeMode == PreferencesManager.THEME_MODE_LIGHT,
|
||||||
|
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_LIGHT) },
|
||||||
|
label = { Text("Light") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = themeMode == PreferencesManager.THEME_MODE_DARK,
|
||||||
|
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_DARK) },
|
||||||
|
label = { Text("Dark") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic color toggle
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Use Material 3 colors")
|
||||||
|
Text(
|
||||||
|
text = "Dynamic colors on supported devices",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = dynamicColor, onCheckedChange = { preferencesManager.setDynamicColorEnabled(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// AMOLED dark toggle
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("AMOLED dark")
|
||||||
|
Text(
|
||||||
|
text = "Pure black background in dark mode",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = amoledDark, onCheckedChange = { preferencesManager.setAmoledDarkEnabled(it) })
|
||||||
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { showLeaveDialog = true },
|
onClick = { showLeaveDialog = true },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -116,31 +190,31 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "About Leave Server:",
|
text = "Leaving removes local data",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "• Disconnects from the current server",
|
text = "Disconnects and clears local database",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "• Clears stored server URL and password",
|
text = "Clears stored server URL and password",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "• Deletes all local data (lists, fields, items)",
|
text = "Deletes all local data (tables, fields, items)",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "• Returns to server setup screen",
|
text = "Returns to server setup screen",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
)
|
)
|
||||||
@@ -211,3 +285,25 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatServerUrlForDisplay(raw: String): String {
|
||||||
|
var s = raw.trim()
|
||||||
|
if (s.isEmpty()) return ""
|
||||||
|
// Remove scheme
|
||||||
|
s = s.replace(Regex("^https?://", RegexOption.IGNORE_CASE), "")
|
||||||
|
// Remove trailing /api or /api/
|
||||||
|
s = s.replace(Regex("/api/?$", RegexOption.IGNORE_CASE), "")
|
||||||
|
// Trim trailing /
|
||||||
|
s = s.trimEnd('/')
|
||||||
|
// Split authority and path (in case any path remains)
|
||||||
|
val firstSlash = s.indexOf('/')
|
||||||
|
val authority = if (firstSlash >= 0) s.substring(0, firstSlash) else s
|
||||||
|
val rest = if (firstSlash >= 0) s.substring(firstSlash) else ""
|
||||||
|
// Remove default ports from authority
|
||||||
|
val authorityStripped = when {
|
||||||
|
authority.endsWith(":80") -> authority.removeSuffix(":80")
|
||||||
|
authority.endsWith(":443") -> authority.removeSuffix(":443")
|
||||||
|
else -> authority
|
||||||
|
}
|
||||||
|
return authorityStripped + rest
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.material3.dynamicLightColorScheme
|
|||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
@@ -31,9 +32,10 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
fun CollabTableTheme(
|
fun CollabTableTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
|
amoledDark: Boolean = false,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
var colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
@@ -41,6 +43,14 @@ fun CollabTableTheme(
|
|||||||
darkTheme -> DarkColorScheme
|
darkTheme -> DarkColorScheme
|
||||||
else -> LightColorScheme
|
else -> LightColorScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (darkTheme && amoledDark) {
|
||||||
|
colorScheme = colorScheme.copy(
|
||||||
|
background = Color.Black,
|
||||||
|
surface = Color.Black,
|
||||||
|
surfaceVariant = Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">CollabTable</string>
|
<string name="app_name">CollabTable</string>
|
||||||
<string name="lists">Lists</string>
|
<string name="lists">Tables</string>
|
||||||
<string name="create_list">Create List</string>
|
<string name="create_list">Create Table</string>
|
||||||
<string name="list_name">List Name</string>
|
<string name="list_name">Table Name</string>
|
||||||
<string name="add_field">Add Field</string>
|
<string name="add_field">Add Field</string>
|
||||||
<string name="field_name">Field Name</string>
|
<string name="field_name">Field Name</string>
|
||||||
<string name="add_item">Add Item</string>
|
<string name="add_item">Add Item</string>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<string name="save">Save</string>
|
<string name="save">Save</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
<string name="edit">Edit</string>
|
<string name="edit">Edit</string>
|
||||||
<string name="no_lists">No lists yet. Create one to get started!</string>
|
<string name="no_lists">No tables yet. Create one to get started!</string>
|
||||||
<string name="no_items">No items yet</string>
|
<string name="no_items">No items yet</string>
|
||||||
<string name="search">Search</string>
|
<string name="search">Search</string>
|
||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<string name="sync">Sync</string>
|
<string name="sync">Sync</string>
|
||||||
<string name="syncing">Syncing…</string>
|
<string name="syncing">Syncing…</string>
|
||||||
<string name="sync_error">Sync error</string>
|
<string name="sync_error">Sync error</string>
|
||||||
<string name="delete_list">Delete List</string>
|
<string name="delete_list">Delete Table</string>
|
||||||
<string name="delete_field">Delete Field</string>
|
<string name="delete_field">Delete Field</string>
|
||||||
<string name="delete_item">Delete Item</string>
|
<string name="delete_item">Delete Item</string>
|
||||||
<string name="confirm_delete">Are you sure you want to delete this?</string>
|
<string name="confirm_delete">Are you sure you want to delete this?</string>
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +1,35 @@
|
|||||||
|
#################################################################
|
||||||
|
# Server
|
||||||
|
#################################################################
|
||||||
PORT=3000
|
PORT=3000
|
||||||
DB_PATH=/data/collabtable.db
|
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
SERVER_PASSWORD=your_secure_password_here
|
SERVER_PASSWORD=your_secure_password_here
|
||||||
|
|
||||||
|
#################################################################
|
||||||
|
# Database backend selection
|
||||||
|
# - sqlite (default if not set)
|
||||||
|
# - postgres
|
||||||
|
#################################################################
|
||||||
|
# Choose the DB client; if omitted or not set to "postgres", SQLite is used
|
||||||
|
DB_CLIENT=sqlite
|
||||||
|
|
||||||
|
#################################################################
|
||||||
|
# SQLite configuration (used when DB_CLIENT=sqlite)
|
||||||
|
#################################################################
|
||||||
|
# Path to the SQLite database file. Default in code: ./data/collabtable.db
|
||||||
|
# For Docker, you may prefer a volume path like /data/collabtable.db
|
||||||
|
DB_PATH=/data/collabtable.db
|
||||||
|
|
||||||
|
#################################################################
|
||||||
|
# PostgreSQL configuration (used when DB_CLIENT=postgres)
|
||||||
|
# You can either provide a single DATABASE_URL or the PG* variables below
|
||||||
|
#################################################################
|
||||||
|
# Example URL: postgres://user:password@localhost:5432/collabtable
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
# If DATABASE_URL is not set, these individual settings are used:
|
||||||
|
PGHOST=localhost
|
||||||
|
PGPORT=5432
|
||||||
|
PGUSER=postgres
|
||||||
|
PGPASSWORD=
|
||||||
|
PGDATABASE=collabtable
|
||||||
|
|||||||
Generated
+185
-2
@@ -12,13 +12,17 @@
|
|||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^11.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"ws": "^8.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
|
"@types/pg": "^8.10.2",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
@@ -183,6 +187,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.15.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz",
|
||||||
|
"integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
@@ -244,6 +260,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -1418,6 +1444,95 @@
|
|||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.9.1",
|
||||||
|
"pg-pool": "^3.10.1",
|
||||||
|
"pg-protocol": "^1.10.3",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.2.7"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||||
|
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
@@ -1431,6 +1546,45 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prebuild-install": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
@@ -1832,6 +1986,15 @@
|
|||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
@@ -2126,11 +2289,31 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
|
|||||||
@@ -16,13 +16,17 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^11.0.0",
|
||||||
"dotenv": "^16.3.1"
|
"dotenv": "^16.3.1",
|
||||||
|
"ws": "^8.16.0",
|
||||||
|
"pg": "^8.11.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"@types/pg": "^8.10.2",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"ts-node-dev": "^2.0.0"
|
"ts-node-dev": "^2.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import BetterSqlite3, { Database as SqliteDB } from 'better-sqlite3';
|
||||||
|
import { Pool, PoolClient, QueryResult } from 'pg';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
type Param = any;
|
||||||
|
|
||||||
|
type ExecResult = { changes: number };
|
||||||
|
|
||||||
|
type TxClient = {
|
||||||
|
query: (text: string, params?: any[]) => Promise<QueryResult<any>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DBAdapter {
|
||||||
|
queryAll(sql: string, params?: Param[]): Promise<any[]>;
|
||||||
|
queryOne(sql: string, params?: Param[]): Promise<any | undefined>;
|
||||||
|
execute(sql: string, params?: Param[]): Promise<ExecResult>;
|
||||||
|
transaction<T>(fn: (tx: DBAdapter) => Promise<T>): Promise<T>;
|
||||||
|
initialize(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDataDir(dbPath: string) {
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceQuotedIdentifiers(sql: string) {
|
||||||
|
// Convert backticks to double quotes for portability
|
||||||
|
return sql.replace(/`([^`]+)`/g, '"$1"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertQMarksToPg(sql: string) {
|
||||||
|
// Replace each ? with $1, $2, ...
|
||||||
|
let idx = 0;
|
||||||
|
return sql.replace(/\?/g, () => `$${++idx}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SQLite Adapter (wrapped async) ----------
|
||||||
|
class SqliteAdapter implements DBAdapter {
|
||||||
|
private db: SqliteDB;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const DB_PATH = process.env.DB_PATH || './data/collabtable.db';
|
||||||
|
ensureDataDir(DB_PATH);
|
||||||
|
this.db = new BetterSqlite3(DB_PATH);
|
||||||
|
this.db.pragma('foreign_keys = ON');
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// Create tables and indexes
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS lists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
isDeleted INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fields (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
fieldType TEXT NOT NULL,
|
||||||
|
fieldOptions TEXT,
|
||||||
|
listId TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
isDeleted INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (listId) REFERENCES lists(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
listId TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
isDeleted INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (listId) REFERENCES lists(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_values (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
itemId TEXT NOT NULL,
|
||||||
|
fieldId TEXT NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (itemId) REFERENCES items(id),
|
||||||
|
FOREIGN KEY (fieldId) REFERENCES fields(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fields_listId ON fields(listId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAll(sql: string, params: Param[] = []): Promise<any[]> {
|
||||||
|
const stmt = this.db.prepare(replaceQuotedIdentifiers(sql));
|
||||||
|
return stmt.all(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryOne(sql: string, params: Param[] = []): Promise<any | undefined> {
|
||||||
|
const stmt = this.db.prepare(replaceQuotedIdentifiers(sql));
|
||||||
|
return stmt.get(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(sql: string, params: Param[] = []): Promise<ExecResult> {
|
||||||
|
const stmt = this.db.prepare(replaceQuotedIdentifiers(sql));
|
||||||
|
const res = stmt.run(...params);
|
||||||
|
return { changes: res.changes || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(fn: (tx: DBAdapter) => Promise<T>): Promise<T> {
|
||||||
|
const tx = this.db.transaction((innerFn: (tx: DBAdapter) => Promise<T>) => {
|
||||||
|
// We can reuse the same adapter since better-sqlite3 is transactional per connection
|
||||||
|
return innerFn(this);
|
||||||
|
});
|
||||||
|
return tx(fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Postgres Adapter ----------
|
||||||
|
class PostgresAdapter implements DBAdapter {
|
||||||
|
private pool: Pool;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (connectionString) {
|
||||||
|
this.pool = new Pool({ connectionString });
|
||||||
|
} else {
|
||||||
|
this.pool = new Pool({
|
||||||
|
host: process.env.PGHOST || 'localhost',
|
||||||
|
port: parseInt(process.env.PGPORT || '5432', 10),
|
||||||
|
user: process.env.PGUSER || 'postgres',
|
||||||
|
password: process.env.PGPASSWORD || '',
|
||||||
|
database: process.env.PGDATABASE || 'collabtable'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// Create tables if not exist, keeping schema aligned with sqlite (isDeleted INTEGER 0/1)
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS lists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
createdAt BIGINT NOT NULL,
|
||||||
|
updatedAt BIGINT NOT NULL,
|
||||||
|
isDeleted INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS fields (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
fieldType TEXT NOT NULL,
|
||||||
|
fieldOptions TEXT,
|
||||||
|
listId TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL,
|
||||||
|
createdAt BIGINT NOT NULL,
|
||||||
|
updatedAt BIGINT NOT NULL,
|
||||||
|
isDeleted INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (listId) REFERENCES lists(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
listId TEXT NOT NULL,
|
||||||
|
createdAt BIGINT NOT NULL,
|
||||||
|
updatedAt BIGINT NOT NULL,
|
||||||
|
isDeleted INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (listId) REFERENCES lists(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS item_values (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
itemId TEXT NOT NULL,
|
||||||
|
fieldId TEXT NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
updatedAt BIGINT NOT NULL,
|
||||||
|
FOREIGN KEY (itemId) REFERENCES items(id),
|
||||||
|
FOREIGN KEY (fieldId) REFERENCES fields(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await client.query(`CREATE INDEX IF NOT EXISTS idx_fields_listId ON fields(listId);`);
|
||||||
|
await client.query(`CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);`);
|
||||||
|
await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);`);
|
||||||
|
await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);`);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runQuery(sql: string, params: Param[] = [], client?: PoolClient): Promise<QueryResult<any>> {
|
||||||
|
// Replace backticks and convert ? to $1..$n
|
||||||
|
const text = convertQMarksToPg(replaceQuotedIdentifiers(sql));
|
||||||
|
const runner = client || this.pool;
|
||||||
|
// @ts-ignore - Pool and PoolClient share query signature
|
||||||
|
return runner.query(text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAll(sql: string, params: Param[] = []): Promise<any[]> {
|
||||||
|
const res = await this.runQuery(sql, params);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryOne(sql: string, params: Param[] = []): Promise<any | undefined> {
|
||||||
|
const res = await this.runQuery(sql, params);
|
||||||
|
return res.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(sql: string, params: Param[] = []): Promise<ExecResult> {
|
||||||
|
const res = await this.runQuery(sql, params);
|
||||||
|
return { changes: res.rowCount || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(fn: (tx: DBAdapter) => Promise<T>): Promise<T> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const txAdapter: DBAdapter = {
|
||||||
|
initialize: async () => {},
|
||||||
|
queryAll: (sql, params) => this.runQuery(sql, params, client).then(r => r.rows),
|
||||||
|
queryOne: (sql, params) => this.runQuery(sql, params, client).then(r => r.rows[0]),
|
||||||
|
execute: (sql, params) => this.runQuery(sql, params, client).then(r => ({ changes: r.rowCount || 0 })),
|
||||||
|
transaction: async (inner) => {
|
||||||
|
// Nested transactions are treated as flat for simplicity
|
||||||
|
return inner(txAdapter);
|
||||||
|
}
|
||||||
|
} as DBAdapter;
|
||||||
|
const result = await fn(txAdapter);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientType = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase();
|
||||||
|
export const dbAdapter: DBAdapter = clientType === 'postgres' || clientType === 'postgresql'
|
||||||
|
? new PostgresAdapter()
|
||||||
|
: new SqliteAdapter();
|
||||||
|
|
||||||
|
export async function initializeDatabase() {
|
||||||
|
await dbAdapter.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { DBAdapter };
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { initializeDatabase } from './database';
|
import { initializeDatabase } from './db';
|
||||||
import { authenticatePassword } from './middleware/auth';
|
import { authenticatePassword } from './middleware/auth';
|
||||||
import listRoutes from './routes/listRoutes';
|
import listRoutes from './routes/listRoutes';
|
||||||
import fieldRoutes from './routes/fieldRoutes';
|
import fieldRoutes from './routes/fieldRoutes';
|
||||||
import itemRoutes from './routes/itemRoutes';
|
import itemRoutes from './routes/itemRoutes';
|
||||||
import syncRoutes from './routes/syncRoutes';
|
import syncRoutes from './routes/syncRoutes';
|
||||||
import webRoutes from './routes/webRoutes';
|
import webRoutes from './routes/webRoutes';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { initWebSocket } from './ws';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -36,8 +38,9 @@ app.use('/api', syncRoutes);
|
|||||||
app.use('/', webRoutes);
|
app.use('/', webRoutes);
|
||||||
|
|
||||||
// Initialize database and start server
|
// Initialize database and start server
|
||||||
try {
|
(async () => {
|
||||||
initializeDatabase();
|
try {
|
||||||
|
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');
|
||||||
@@ -45,14 +48,23 @@ try {
|
|||||||
console.log('- Items');
|
console.log('- Items');
|
||||||
console.log('- ItemValues');
|
console.log('- ItemValues');
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
const server = createServer(app);
|
||||||
|
initWebSocket(server);
|
||||||
|
server.listen(PORT, () => {
|
||||||
console.log(`Server is running on port ${PORT}`);
|
console.log(`Server is running on port ${PORT}`);
|
||||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
const dbClient = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase();
|
||||||
|
if (dbClient === 'postgres' || dbClient === 'postgresql') {
|
||||||
|
const source = process.env.DATABASE_URL ? 'DATABASE_URL' : 'PGHOST/PGDATABASE';
|
||||||
|
console.log(`Database: PostgreSQL (${source})`);
|
||||||
|
} else {
|
||||||
console.log(`Database: ${process.env.DB_PATH || './data/collabtable.db'}`);
|
console.log(`Database: ${process.env.DB_PATH || './data/collabtable.db'}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Database initialization error:', error);
|
console.error('Database initialization error:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { db } from '../database';
|
import { dbAdapter } from '../db';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Get fields for a list
|
// Get fields for a list
|
||||||
router.get('/list/:listId', async (req: Request, res: Response) => {
|
router.get('/list/:listId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const fields = db.prepare('SELECT * FROM fields WHERE listId = ? AND isDeleted = 0 ORDER BY `order` ASC').all(req.params.listId);
|
const fields = await dbAdapter.queryAll('SELECT * FROM fields WHERE listId = ? AND isDeleted = 0 ORDER BY "order" ASC', [req.params.listId]);
|
||||||
const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
|
const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
|
||||||
res.json(formattedFields);
|
res.json(formattedFields);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -18,12 +18,11 @@ router.get('/list/:listId', async (req: Request, res: Response) => {
|
|||||||
router.post('/', async (req: Request, res: Response) => {
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted } = req.body;
|
const { id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted } = req.body;
|
||||||
db.prepare(`
|
await dbAdapter.execute(
|
||||||
INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted)
|
'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
[id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0]
|
||||||
`).run(id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0);
|
);
|
||||||
|
const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [id]);
|
||||||
const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(id);
|
|
||||||
res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
|
res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to create field' });
|
res.status(500).json({ error: 'Failed to create field' });
|
||||||
@@ -35,17 +34,16 @@ router.put('/:id', async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
const { name, fieldType, fieldOptions, order } = req.body;
|
const { name, fieldType, fieldOptions, order } = req.body;
|
||||||
const result = db.prepare(`
|
const result = await dbAdapter.execute(
|
||||||
UPDATE fields
|
'UPDATE fields SET name = ?, fieldType = ?, fieldOptions = ?, "order" = ?, updatedAt = ? WHERE id = ?',
|
||||||
SET name = ?, fieldType = ?, fieldOptions = ?, \`order\` = ?, updatedAt = ?
|
[name, fieldType, fieldOptions, order, updatedAt, req.params.id]
|
||||||
WHERE id = ?
|
);
|
||||||
`).run(name, fieldType, fieldOptions, order, updatedAt, req.params.id);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({ error: 'Field not found' });
|
return res.status(404).json({ error: 'Field not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(req.params.id);
|
const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [req.params.id]);
|
||||||
res.json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
|
res.json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to update field' });
|
res.status(500).json({ error: 'Failed to update field' });
|
||||||
@@ -56,11 +54,10 @@ router.put('/:id', async (req: Request, res: Response) => {
|
|||||||
router.delete('/:id', async (req: Request, res: Response) => {
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
const result = db.prepare(`
|
const result = await dbAdapter.execute(
|
||||||
UPDATE fields
|
'UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE id = ?',
|
||||||
SET isDeleted = 1, updatedAt = ?
|
[updatedAt, req.params.id]
|
||||||
WHERE id = ?
|
);
|
||||||
`).run(updatedAt, req.params.id);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({ error: 'Field not found' });
|
return res.status(404).json({ error: 'Field not found' });
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { db } from '../database';
|
import { dbAdapter } from '../db';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Get items for a list
|
// Get items for a list
|
||||||
router.get('/list/:listId', async (req: Request, res: Response) => {
|
router.get('/list/:listId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const items = db.prepare('SELECT * FROM items WHERE listId = ? AND isDeleted = 0 ORDER BY updatedAt DESC').all(req.params.listId);
|
const items = await dbAdapter.queryAll('SELECT * FROM items WHERE listId = ? AND isDeleted = 0 ORDER BY updatedAt DESC', [req.params.listId]);
|
||||||
const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
|
const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
|
||||||
res.json(formattedItems);
|
res.json(formattedItems);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -17,7 +17,7 @@ router.get('/list/:listId', async (req: Request, res: Response) => {
|
|||||||
// Get item values
|
// Get item values
|
||||||
router.get('/:itemId/values', async (req: Request, res: Response) => {
|
router.get('/:itemId/values', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const values = db.prepare('SELECT * FROM item_values WHERE itemId = ?').all(req.params.itemId);
|
const values = await dbAdapter.queryAll('SELECT * FROM item_values WHERE itemId = ?', [req.params.itemId]);
|
||||||
res.json(values);
|
res.json(values);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch item values' });
|
res.status(500).json({ error: 'Failed to fetch item values' });
|
||||||
@@ -28,12 +28,11 @@ router.get('/:itemId/values', async (req: Request, res: Response) => {
|
|||||||
router.post('/', async (req: Request, res: Response) => {
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id, listId, createdAt, updatedAt, isDeleted } = req.body;
|
const { id, listId, createdAt, updatedAt, isDeleted } = req.body;
|
||||||
db.prepare(`
|
await dbAdapter.execute(
|
||||||
INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted)
|
'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?)',
|
||||||
VALUES (?, ?, ?, ?, ?)
|
[id, listId, createdAt, updatedAt, isDeleted ? 1 : 0]
|
||||||
`).run(id, listId, createdAt, updatedAt, isDeleted ? 1 : 0);
|
);
|
||||||
|
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [id]);
|
||||||
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id);
|
|
||||||
res.status(201).json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
|
res.status(201).json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to create item' });
|
res.status(500).json({ error: 'Failed to create item' });
|
||||||
@@ -44,15 +43,11 @@ router.post('/', async (req: Request, res: Response) => {
|
|||||||
router.post('/values', async (req: Request, res: Response) => {
|
router.post('/values', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id, itemId, fieldId, value, updatedAt } = req.body;
|
const { id, itemId, fieldId, value, updatedAt } = req.body;
|
||||||
db.prepare(`
|
await dbAdapter.execute(
|
||||||
INSERT INTO item_values (id, itemId, fieldId, value, updatedAt)
|
'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = ?, updatedAt = ?',
|
||||||
VALUES (?, ?, ?, ?, ?)
|
[id, itemId, fieldId, value, updatedAt, value, updatedAt]
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
);
|
||||||
value = ?,
|
const itemValue = await dbAdapter.queryOne('SELECT * FROM item_values WHERE id = ?', [id]);
|
||||||
updatedAt = ?
|
|
||||||
`).run(id, itemId, fieldId, value, updatedAt, value, updatedAt);
|
|
||||||
|
|
||||||
const itemValue = db.prepare('SELECT * FROM item_values WHERE id = ?').get(id);
|
|
||||||
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' });
|
||||||
@@ -63,17 +58,16 @@ router.post('/values', async (req: Request, res: Response) => {
|
|||||||
router.put('/:id', async (req: Request, res: Response) => {
|
router.put('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
const result = db.prepare(`
|
const result = await dbAdapter.execute(
|
||||||
UPDATE items
|
'UPDATE items SET updatedAt = ? WHERE id = ?',
|
||||||
SET updatedAt = ?
|
[updatedAt, req.params.id]
|
||||||
WHERE id = ?
|
);
|
||||||
`).run(updatedAt, req.params.id);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({ error: 'Item not found' });
|
return res.status(404).json({ error: 'Item not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id);
|
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]);
|
||||||
res.json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
|
res.json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to update item' });
|
res.status(500).json({ error: 'Failed to update item' });
|
||||||
@@ -84,11 +78,10 @@ router.put('/:id', async (req: Request, res: Response) => {
|
|||||||
router.delete('/:id', async (req: Request, res: Response) => {
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
const result = db.prepare(`
|
const result = await dbAdapter.execute(
|
||||||
UPDATE items
|
'UPDATE items SET isDeleted = 1, updatedAt = ? WHERE id = ?',
|
||||||
SET isDeleted = 1, updatedAt = ?
|
[updatedAt, req.params.id]
|
||||||
WHERE id = ?
|
);
|
||||||
`).run(updatedAt, req.params.id);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({ error: 'Item not found' });
|
return res.status(404).json({ error: 'Item not found' });
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { db } from '../database';
|
import { dbAdapter } from '../db';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Get all lists
|
// Get all lists
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const lists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC').all();
|
const lists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC');
|
||||||
// Convert isDeleted from INTEGER to BOOLEAN
|
// Convert isDeleted from INTEGER to BOOLEAN
|
||||||
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
|
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
|
||||||
res.json(formattedLists);
|
res.json(formattedLists);
|
||||||
@@ -18,7 +18,7 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
// Get single list
|
// Get single list
|
||||||
router.get('/:id', async (req: Request, res: Response) => {
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const list = db.prepare('SELECT * FROM lists WHERE id = ? AND isDeleted = 0').get(req.params.id);
|
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ? AND isDeleted = 0', [req.params.id]);
|
||||||
if (!list) {
|
if (!list) {
|
||||||
return res.status(404).json({ error: 'List not found' });
|
return res.status(404).json({ error: 'List not found' });
|
||||||
}
|
}
|
||||||
@@ -32,12 +32,11 @@ router.get('/:id', async (req: Request, res: Response) => {
|
|||||||
router.post('/', async (req: Request, res: Response) => {
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id, name, createdAt, updatedAt, isDeleted } = req.body;
|
const { id, name, createdAt, updatedAt, isDeleted } = req.body;
|
||||||
db.prepare(`
|
await dbAdapter.execute(
|
||||||
INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted)
|
'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?)',
|
||||||
VALUES (?, ?, ?, ?, ?)
|
[id, name, createdAt, updatedAt, isDeleted ? 1 : 0]
|
||||||
`).run(id, name, createdAt, updatedAt, isDeleted ? 1 : 0);
|
);
|
||||||
|
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [id]);
|
||||||
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(id);
|
|
||||||
res.status(201).json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
|
res.status(201).json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to create list' });
|
res.status(500).json({ error: 'Failed to create list' });
|
||||||
@@ -48,17 +47,15 @@ router.post('/', async (req: Request, res: Response) => {
|
|||||||
router.put('/:id', async (req: Request, res: Response) => {
|
router.put('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
const result = db.prepare(`
|
const result = await dbAdapter.execute(
|
||||||
UPDATE lists
|
'UPDATE lists SET name = ?, updatedAt = ? WHERE id = ?',
|
||||||
SET name = ?, updatedAt = ?
|
[req.body.name, updatedAt, req.params.id]
|
||||||
WHERE id = ?
|
);
|
||||||
`).run(req.body.name, updatedAt, req.params.id);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({ error: 'List not found' });
|
return res.status(404).json({ error: 'List not found' });
|
||||||
}
|
}
|
||||||
|
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [req.params.id]);
|
||||||
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(req.params.id);
|
|
||||||
res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
|
res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to update list' });
|
res.status(500).json({ error: 'Failed to update list' });
|
||||||
@@ -69,11 +66,10 @@ router.put('/:id', async (req: Request, res: Response) => {
|
|||||||
router.delete('/:id', async (req: Request, res: Response) => {
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
const result = db.prepare(`
|
const result = await dbAdapter.execute(
|
||||||
UPDATE lists
|
'UPDATE lists SET isDeleted = 1, updatedAt = ? WHERE id = ?',
|
||||||
SET isDeleted = 1, updatedAt = ?
|
[updatedAt, req.params.id]
|
||||||
WHERE id = ?
|
);
|
||||||
`).run(updatedAt, req.params.id);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({ error: 'List not found' });
|
return res.status(404).json({ error: 'List not found' });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { db } from '../database';
|
import { dbAdapter } from '../db';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -57,43 +57,11 @@ setInterval(() => {
|
|||||||
}, 60000); // 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)
|
||||||
function getUpsertStatements() {
|
// We'll perform upserts with positional params for cross-DB portability
|
||||||
return {
|
const UPSERT_LIST = 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
||||||
upsertList: db.prepare(`
|
const UPSERT_FIELD = 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, fieldType = EXCLUDED.fieldType, fieldOptions = EXCLUDED.fieldOptions, "order" = EXCLUDED."order", updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
||||||
INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted)
|
const UPSERT_ITEM = 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
||||||
VALUES (@id, @name, @createdAt, @updatedAt, @isDeleted)
|
const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = EXCLUDED.value, updatedAt = EXCLUDED.updatedAt';
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
name = @name,
|
|
||||||
updatedAt = @updatedAt,
|
|
||||||
isDeleted = @isDeleted
|
|
||||||
`),
|
|
||||||
upsertField: db.prepare(`
|
|
||||||
INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted)
|
|
||||||
VALUES (@id, @name, @fieldType, @fieldOptions, @listId, @order, @createdAt, @updatedAt, @isDeleted)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
name = @name,
|
|
||||||
fieldType = @fieldType,
|
|
||||||
fieldOptions = @fieldOptions,
|
|
||||||
\`order\` = @order,
|
|
||||||
updatedAt = @updatedAt,
|
|
||||||
isDeleted = @isDeleted
|
|
||||||
`),
|
|
||||||
upsertItem: db.prepare(`
|
|
||||||
INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted)
|
|
||||||
VALUES (@id, @listId, @createdAt, @updatedAt, @isDeleted)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
updatedAt = @updatedAt,
|
|
||||||
isDeleted = @isDeleted
|
|
||||||
`),
|
|
||||||
upsertItemValue: db.prepare(`
|
|
||||||
INSERT INTO item_values (id, itemId, fieldId, value, updatedAt)
|
|
||||||
VALUES (@id, @itemId, @fieldId, @value, @updatedAt)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
value = @value,
|
|
||||||
updatedAt = @updatedAt
|
|
||||||
`)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post('/sync', async (req: Request, res: Response) => {
|
router.post('/sync', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -144,65 +112,60 @@ router.post('/sync', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save incoming data from client using a transaction
|
// Save incoming data from client using a transaction
|
||||||
const transaction = db.transaction((data: SyncRequest) => {
|
await dbAdapter.transaction(async (tx) => {
|
||||||
// Get prepared statements lazily
|
if (lists && lists.length > 0) {
|
||||||
const { upsertList, upsertField, upsertItem, upsertItemValue } = getUpsertStatements();
|
for (const list of lists) {
|
||||||
|
await tx.execute(UPSERT_LIST, [
|
||||||
if (data.lists && data.lists.length > 0) {
|
list.id,
|
||||||
for (const list of data.lists) {
|
list.name,
|
||||||
upsertList.run({
|
list.createdAt,
|
||||||
id: list.id,
|
list.updatedAt,
|
||||||
name: list.name,
|
list.isDeleted ? 1 : 0
|
||||||
createdAt: list.createdAt,
|
]);
|
||||||
updatedAt: list.updatedAt,
|
|
||||||
isDeleted: list.isDeleted ? 1 : 0
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.fields && data.fields.length > 0) {
|
if (fields && fields.length > 0) {
|
||||||
for (const field of data.fields) {
|
for (const field of fields) {
|
||||||
upsertField.run({
|
await tx.execute(UPSERT_FIELD, [
|
||||||
id: field.id,
|
field.id,
|
||||||
name: field.name,
|
field.name,
|
||||||
fieldType: field.fieldType,
|
field.fieldType,
|
||||||
fieldOptions: field.fieldOptions,
|
field.fieldOptions,
|
||||||
listId: field.listId,
|
field.listId,
|
||||||
order: field.order,
|
field.order,
|
||||||
createdAt: field.createdAt,
|
field.createdAt,
|
||||||
updatedAt: field.updatedAt,
|
field.updatedAt,
|
||||||
isDeleted: field.isDeleted ? 1 : 0
|
field.isDeleted ? 1 : 0
|
||||||
});
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.items && data.items.length > 0) {
|
if (items && items.length > 0) {
|
||||||
for (const item of data.items) {
|
for (const item of items) {
|
||||||
upsertItem.run({
|
await tx.execute(UPSERT_ITEM, [
|
||||||
id: item.id,
|
item.id,
|
||||||
listId: item.listId,
|
item.listId,
|
||||||
createdAt: item.createdAt,
|
item.createdAt,
|
||||||
updatedAt: item.updatedAt,
|
item.updatedAt,
|
||||||
isDeleted: item.isDeleted ? 1 : 0
|
item.isDeleted ? 1 : 0
|
||||||
});
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.itemValues && data.itemValues.length > 0) {
|
if (itemValues && itemValues.length > 0) {
|
||||||
for (const value of data.itemValues) {
|
for (const value of itemValues) {
|
||||||
upsertItemValue.run({
|
await tx.execute(UPSERT_ITEM_VALUE, [
|
||||||
id: value.id,
|
value.id,
|
||||||
itemId: value.itemId,
|
value.itemId,
|
||||||
fieldId: value.fieldId,
|
value.fieldId,
|
||||||
value: value.value,
|
value.value,
|
||||||
updatedAt: value.updatedAt
|
value.updatedAt
|
||||||
});
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
transaction({ lastSyncTimestamp, lists, fields, items, itemValues });
|
|
||||||
|
|
||||||
// IMPORTANT: Get updates from server AFTER saving incoming data
|
// IMPORTANT: Get updates from server AFTER saving incoming data
|
||||||
// This ensures clients receive back any data they just sent (for confirmation)
|
// This ensures clients receive back any data they just sent (for confirmation)
|
||||||
// If lastSyncTimestamp is 0, get all data (initial sync)
|
// If lastSyncTimestamp is 0, get all data (initial sync)
|
||||||
@@ -210,17 +173,15 @@ router.post('/sync', async (req: Request, res: Response) => {
|
|||||||
let serverLists, serverFields, serverItems, serverItemValues;
|
let serverLists, serverFields, serverItems, serverItemValues;
|
||||||
|
|
||||||
if (lastSyncTimestamp === 0) {
|
if (lastSyncTimestamp === 0) {
|
||||||
// Initial sync: send all non-deleted items (AFTER processing incoming data)
|
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0');
|
||||||
serverLists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0').all();
|
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE isDeleted = 0');
|
||||||
serverFields = db.prepare('SELECT * FROM fields WHERE isDeleted = 0').all();
|
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0');
|
||||||
serverItems = db.prepare('SELECT * FROM items WHERE isDeleted = 0').all();
|
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values');
|
||||||
serverItemValues = db.prepare('SELECT * FROM item_values').all();
|
|
||||||
} else {
|
} else {
|
||||||
// Incremental sync: send all updates including deletions (use >= to include items updated exactly at lastSyncTimestamp)
|
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||||
serverLists = db.prepare('SELECT * FROM lists WHERE updatedAt >= ?').all(lastSyncTimestamp);
|
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||||
serverFields = db.prepare('SELECT * FROM fields WHERE updatedAt >= ?').all(lastSyncTimestamp);
|
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||||
serverItems = db.prepare('SELECT * FROM items WHERE updatedAt >= ?').all(lastSyncTimestamp);
|
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [lastSyncTimestamp]);
|
||||||
serverItemValues = db.prepare('SELECT * FROM item_values WHERE updatedAt >= ?').all(lastSyncTimestamp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert isDeleted from INTEGER (0/1) to BOOLEAN
|
// Convert isDeleted from INTEGER (0/1) to BOOLEAN
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { db } from '../database';
|
import { dbAdapter } from '../db';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -306,10 +306,11 @@ router.get('/web/data', async (req: Request, res: Response) => {
|
|||||||
res.setHeader('Pragma', 'no-cache');
|
res.setHeader('Pragma', 'no-cache');
|
||||||
res.setHeader('Expires', '0');
|
res.setHeader('Expires', '0');
|
||||||
|
|
||||||
const lists = db.prepare('SELECT * FROM lists ORDER BY updatedAt DESC').all();
|
// Exclude deleted lists and items from web UI
|
||||||
const fields = db.prepare('SELECT * FROM fields ORDER BY listId ASC, `order` ASC').all();
|
const lists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC');
|
||||||
const items = db.prepare('SELECT * FROM items ORDER BY listId ASC, updatedAt DESC').all();
|
const fields = await dbAdapter.queryAll('SELECT * FROM fields ORDER BY listId ASC, "order" ASC');
|
||||||
const values = db.prepare('SELECT * FROM item_values').all();
|
const items = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0 ORDER BY listId ASC, updatedAt DESC');
|
||||||
|
const values = await dbAdapter.queryAll('SELECT * FROM item_values');
|
||||||
|
|
||||||
// Convert isDeleted from INTEGER to BOOLEAN
|
// Convert isDeleted from INTEGER to BOOLEAN
|
||||||
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
|
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Server as HttpServer, IncomingMessage } from 'http';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { dbAdapter } from './db';
|
||||||
|
|
||||||
|
interface SyncRequest {
|
||||||
|
lastSyncTimestamp: number;
|
||||||
|
lists: any[];
|
||||||
|
fields: any[];
|
||||||
|
items: any[];
|
||||||
|
itemValues: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageEnvelope<T = any> {
|
||||||
|
id?: string;
|
||||||
|
type: string;
|
||||||
|
payload?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAuth(req: any): boolean {
|
||||||
|
const serverPassword = process.env.SERVER_PASSWORD;
|
||||||
|
if (!serverPassword) return true; // auth disabled
|
||||||
|
const auth = req.headers?.authorization as string | undefined;
|
||||||
|
if (!auth) return false;
|
||||||
|
const parts = auth.split(' ');
|
||||||
|
return parts.length === 2 && parts[0] === 'Bearer' && parts[1] === serverPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPSERT_LIST = 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
||||||
|
const UPSERT_FIELD = 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, fieldType = EXCLUDED.fieldType, fieldOptions = EXCLUDED.fieldOptions, "order" = EXCLUDED."order", updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
||||||
|
const UPSERT_ITEM = 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
|
||||||
|
const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = EXCLUDED.value, updatedAt = EXCLUDED.updatedAt';
|
||||||
|
|
||||||
|
async function processSync(data: SyncRequest) {
|
||||||
|
await dbAdapter.transaction(async (tx) => {
|
||||||
|
for (const list of data.lists || []) {
|
||||||
|
await tx.execute(UPSERT_LIST, [list.id, list.name, list.createdAt, list.updatedAt, list.isDeleted ? 1 : 0]);
|
||||||
|
}
|
||||||
|
for (const field of data.fields || []) {
|
||||||
|
await tx.execute(UPSERT_FIELD, [field.id, field.name, field.fieldType, field.fieldOptions, field.listId, field.order, field.createdAt, field.updatedAt, field.isDeleted ? 1 : 0]);
|
||||||
|
}
|
||||||
|
for (const item of data.items || []) {
|
||||||
|
await tx.execute(UPSERT_ITEM, [item.id, item.listId, item.createdAt, item.updatedAt, item.isDeleted ? 1 : 0]);
|
||||||
|
}
|
||||||
|
for (const val of data.itemValues || []) {
|
||||||
|
await tx.execute(UPSERT_ITEM_VALUE, [val.id, val.itemId, val.fieldId, val.value, val.updatedAt]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let serverLists: any[], serverFields: any[], serverItems: any[], serverItemValues: any[];
|
||||||
|
const since = data.lastSyncTimestamp;
|
||||||
|
if (since === 0) {
|
||||||
|
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0');
|
||||||
|
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE isDeleted = 0');
|
||||||
|
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0');
|
||||||
|
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values');
|
||||||
|
} else {
|
||||||
|
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [since]);
|
||||||
|
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [since]);
|
||||||
|
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [since]);
|
||||||
|
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [since]);
|
||||||
|
}
|
||||||
|
|
||||||
|
serverLists = serverLists.map(l => ({ ...l, isDeleted: !!l.isDeleted }));
|
||||||
|
serverFields = serverFields.map(f => ({ ...f, isDeleted: !!f.isDeleted }));
|
||||||
|
serverItems = serverItems.map(i => ({ ...i, isDeleted: !!i.isDeleted }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
lists: serverLists,
|
||||||
|
fields: serverFields,
|
||||||
|
items: serverItems,
|
||||||
|
itemValues: serverItemValues,
|
||||||
|
serverTimestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initWebSocket(server: HttpServer) {
|
||||||
|
const wss = new WebSocketServer({ server, path: '/api/ws' });
|
||||||
|
console.log('WebSocket server initialized at /api/ws');
|
||||||
|
|
||||||
|
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
|
||||||
|
if (!checkAuth(req)) {
|
||||||
|
ws.close(1008, 'Unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('message', async (raw: Buffer) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString()) as MessageEnvelope;
|
||||||
|
if (msg.type === 'ping') {
|
||||||
|
const resp: MessageEnvelope = { type: 'pong', id: msg.id };
|
||||||
|
ws.send(JSON.stringify(resp));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'sync') {
|
||||||
|
const respData = await processSync(msg.payload as SyncRequest);
|
||||||
|
const resp: MessageEnvelope = { type: 'syncResponse', id: msg.id, payload: respData };
|
||||||
|
ws.send(JSON.stringify(resp));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const err: MessageEnvelope = { type: 'error', payload: { message: 'Invalid message' } };
|
||||||
|
ws.send(JSON.stringify(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"lib": ["ES2020"],
|
"lib": ["ES2020"],
|
||||||
"types": ["node"],
|
"types": ["node", "ws"],
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user