Compare commits
10
Commits
cd57e97cff
...
bd01936bf6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd01936bf6 | ||
|
|
c9541eb618 | ||
|
|
027b49d90e | ||
|
|
6dba8aaa2e | ||
|
|
d74e243cf7 | ||
|
|
af0d8b77a5 | ||
|
|
2434383953 | ||
|
|
27797679ac | ||
|
|
95753f617b | ||
|
|
e8d2408c8e |
No files matched your search
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="com.nbadal.ktlint.KtlintProjectSettings">
|
||||
<ktlintRulesetVersion>DEFAULT</ktlintRulesetVersion>
|
||||
</component>
|
||||
</project>
|
||||
@@ -19,6 +19,7 @@ repositories {
|
||||
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.materialIconsExtended)
|
||||
|
||||
// Global hotkeys
|
||||
implementation("com.github.kwhat:jnativehook:2.2.2")
|
||||
@@ -39,6 +40,7 @@ dependencies {
|
||||
// Logging
|
||||
implementation("org.apache.logging.log4j:log4j-api:2.24.3")
|
||||
implementation("org.apache.logging.log4j:log4j-core:2.24.3")
|
||||
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.24.3")
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
@@ -47,6 +49,8 @@ compose.desktop {
|
||||
|
||||
// Allow access to AWT Classes to control the window
|
||||
jvmArgs("--add-opens", "java.desktop/com.apple.eawt=ALL-UNNAMED")
|
||||
// Skiko / JNativeHook load native libraries via System.load() — JDK 22+ requires explicit native access
|
||||
jvmArgs("--enable-native-access=ALL-UNNAMED")
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg)
|
||||
|
||||
+22
-9
@@ -1,39 +1,52 @@
|
||||
### Get Spaces
|
||||
GET http://localhost:31009/v1/spaces
|
||||
Authorization: Bearer {{apiKey}}
|
||||
Anytype-Version: {{apiVersion}}
|
||||
|
||||
### Get Types
|
||||
GET http://localhost:31009/v1/spaces/{{spaceId}}/types
|
||||
Authorization: Bearer {{apiKey}}
|
||||
Anytype-Version: 0.1
|
||||
Anytype-Version: {{apiVersion}}
|
||||
|
||||
### Get Type
|
||||
GET http://localhost:31009/v1/spaces/{{spaceId}}/types/bafyreidtsobmbfmf6gl7bfwyd3ik3zs4m7t6e7p46y4lonsz3chkgeeabi
|
||||
GET http://localhost:31009/v1/spaces/{{spaceId}}/types/{{taskTypeId}}
|
||||
Authorization: Bearer {{apiKey}}
|
||||
Anytype-Version: 0.1
|
||||
Anytype-Version: {{apiVersion}}
|
||||
|
||||
### Get objects of type
|
||||
POST http://localhost:31009/v1/spaces/{{spaceId}}/search?type=project
|
||||
Authorization: Bearer {{apiKey}}
|
||||
Anytype-Version: 0.1
|
||||
Anytype-Version: {{apiVersion}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"types": ["project"]
|
||||
}
|
||||
|
||||
### Get Property
|
||||
GET http://localhost:31009/v1/spaces/{{spaceId}}/properties/bafyreibs7xr3zphtvjt3t4bk3jhcxlllo6jskrsatnuvkrcs65k3kfuany
|
||||
### Get Property Tags (Values) Priority
|
||||
GET http://localhost:31009/v1/spaces/{{spaceId}}/properties/{{priorityPropertyId}}/tags
|
||||
Authorization: Bearer {{apiKey}}
|
||||
Anytype-Version: 0.1
|
||||
Anytype-Version: {{apiVersion}}
|
||||
|
||||
### Get Property Values Project
|
||||
GET http://localhost:31009/v1/spaces/{{spaceId}}/properties/{{projectPropertyId}}/tags
|
||||
Authorization: Bearer {{apiKey}}
|
||||
Anytype-Version: {{apiVersion}}
|
||||
|
||||
### Get Templates
|
||||
GET http://localhost:31009/v1/spaces/{{spaceId}}/types/{{taskTypeId}}/templates
|
||||
Authorization: Bearer {{apiKey}}
|
||||
Anytype-Version: {{apiVersion}}
|
||||
|
||||
### Create Task
|
||||
POST http://localhost:31009/v1/spaces/{{spaceId}}/objects
|
||||
Authorization: Bearer {{apiKey}}
|
||||
Anytype-Version: {{apiVersion}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Test Task II",
|
||||
"body": "Created via HTTP client",
|
||||
"name": "With Template - II",
|
||||
"body": "And here comes the body",
|
||||
"template_id": "bafyreidivup2xrqn4xbgimukkgst3ao24izqnpxdesbkdtnc5owvhf6i6i",
|
||||
"type_key": "ot-task"
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"local": {
|
||||
"apiKey": "<API-Key>",
|
||||
"spaceId": "<Space-Id>"
|
||||
"apiVersion": "2025-11-08",
|
||||
"spaceId": "<Space-Id>",
|
||||
"taskTypeId": "<taskTypeId>",
|
||||
"priorityPropertyId": "<priorityPropertyId>"
|
||||
}
|
||||
}
|
||||
@@ -65,19 +65,7 @@ fun main() {
|
||||
onSettings = { isSettingsVisible = true },
|
||||
onExit = { exitApplication() },
|
||||
)
|
||||
client.getSpaces()
|
||||
.onSuccess { spaces ->
|
||||
logger.info("Available spaces:")
|
||||
spaces.forEach { logger.info("Space: ${it.id} ${it.name}") }
|
||||
val space = spaces.find { space -> space.id == configResult.config.spaceId }
|
||||
if (space == null) {
|
||||
logger.error("Selected space not found: ${configResult.config.spaceId}")
|
||||
exitProcess(-1)
|
||||
} else {
|
||||
logger.info("Selected space: ${space.name} (${space.id})")
|
||||
}
|
||||
}
|
||||
.onFailure { logger.error("Failed to load spaces: ${it.message}") }
|
||||
logger.info("AnyTask started. Press ${HotkeyManager.DESCRIPTION} to open the task window.")
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -129,7 +117,6 @@ fun main() {
|
||||
}
|
||||
SettingsWindow(
|
||||
config = configResult.config,
|
||||
client = client,
|
||||
onSave = { newConfig ->
|
||||
saveConfig(newConfig, configResult.sourceFile)
|
||||
configResult = ConfigResult(newConfig, configResult.sourceFile)
|
||||
@@ -166,7 +153,7 @@ private fun setupSystemTray(onShow: () -> Unit, onSettings: () -> Unit, onExit:
|
||||
val icon = createTrayIcon(size)
|
||||
|
||||
val menu = PopupMenu().apply {
|
||||
add(MenuItem("Open").also { it.addActionListener { onShow() } })
|
||||
add(MenuItem("Create Task").also { it.addActionListener { onShow() } })
|
||||
add(MenuItem("Settings").also { it.addActionListener { onSettings() } })
|
||||
addSeparator()
|
||||
add(MenuItem("Quit").also { it.addActionListener { onExit() } })
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
package ch.ergon.anytask.api
|
||||
|
||||
import ch.ergon.anytask.api.dto.CreateObjectRequest
|
||||
import ch.ergon.anytask.api.dto.CreateObjectResponse
|
||||
import ch.ergon.anytask.api.dto.ObjectType
|
||||
import ch.ergon.anytask.api.dto.SearchObject
|
||||
import ch.ergon.anytask.api.dto.SearchObjectsResponse
|
||||
import ch.ergon.anytask.api.dto.Space
|
||||
import ch.ergon.anytask.api.dto.SpacesResponse
|
||||
import ch.ergon.anytask.api.dto.TypesResponse
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.HttpSend
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.plugin
|
||||
import io.ktor.client.request.bearerAuth
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import ch.ergon.anytask.api.dto.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.content.OutgoingContent
|
||||
import io.ktor.http.content.TextContent
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import kotlin.time.DurationUnit.MILLISECONDS
|
||||
import kotlin.time.toDuration
|
||||
@@ -45,13 +33,24 @@ class AnyTypeApi(
|
||||
|
||||
private val http = HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
json(Json { ignoreUnknownKeys = true; explicitNulls = false })
|
||||
}
|
||||
defaultRequest {
|
||||
header("Anytype-Version", "2025-11-08")
|
||||
}
|
||||
}.also { client ->
|
||||
client.plugin(HttpSend).intercept { request ->
|
||||
val method = request.method.value
|
||||
val url = request.url.buildString()
|
||||
logger.info("→ $method $url")
|
||||
if (logger.isTraceEnabled) {
|
||||
val bodyText = when (val body = request.body) {
|
||||
is TextContent -> body.text
|
||||
is OutgoingContent.ByteArrayContent -> body.bytes().decodeToString()
|
||||
else -> null
|
||||
}
|
||||
if (bodyText != null) logger.trace("Request body: $bodyText")
|
||||
}
|
||||
val start = System.currentTimeMillis()
|
||||
val call = execute(request)
|
||||
val duration = (System.currentTimeMillis() - start).toDuration(MILLISECONDS)
|
||||
@@ -67,7 +66,6 @@ class AnyTypeApi(
|
||||
suspend fun getSpaces(): Result<List<Space>> =
|
||||
runCatching {
|
||||
val response = http.get("$baseUrl/v1/spaces") {
|
||||
header("Anytype-Version", "0.1")
|
||||
bearerAuth(apiKey)
|
||||
}
|
||||
response.body<SpacesResponse>().data
|
||||
@@ -76,7 +74,6 @@ class AnyTypeApi(
|
||||
suspend fun getTypes(): Result<List<ObjectType>> =
|
||||
runCatching {
|
||||
val response = http.get("$baseUrl/v1/spaces/$spaceId/types") {
|
||||
header("Anytype-Version", "0.1")
|
||||
bearerAuth(apiKey)
|
||||
}
|
||||
response.body<TypesResponse>().data
|
||||
@@ -86,29 +83,45 @@ class AnyTypeApi(
|
||||
runCatching {
|
||||
val response = http.post("$baseUrl/v1/spaces/$spaceId/search") {
|
||||
contentType(ContentType.Application.Json)
|
||||
header("Anytype-Version", "0.1")
|
||||
bearerAuth(apiKey)
|
||||
setBody(mapOf("types" to listOf(typeKey)))
|
||||
}
|
||||
response.body<SearchObjectsResponse>().data
|
||||
}
|
||||
|
||||
suspend fun getTags(propertyId: String): Result<List<Tag>> =
|
||||
runCatching {
|
||||
val response = http.get("$baseUrl/v1/spaces/$spaceId/properties/$propertyId/tags") {
|
||||
bearerAuth(apiKey)
|
||||
}
|
||||
response.body<TagsResponse>().data
|
||||
}
|
||||
|
||||
suspend fun getTemplates(typeId: String): Result<List<Template>> =
|
||||
runCatching {
|
||||
val response = http.get("$baseUrl/v1/spaces/$spaceId/types/$typeId/templates") {
|
||||
bearerAuth(apiKey)
|
||||
}
|
||||
response.body<TemplatesResponse>().data
|
||||
}
|
||||
|
||||
suspend fun createObject(
|
||||
title: String,
|
||||
details: String = "",
|
||||
details: String? = null,
|
||||
typeKey: String,
|
||||
templateId: String? = null,
|
||||
properties: List<JsonElement> = emptyList(),
|
||||
): Result<String> =
|
||||
runCatching {
|
||||
val response = http.post("$baseUrl/v1/spaces/$spaceId/objects") {
|
||||
contentType(ContentType.Application.Json)
|
||||
header("Anytype-Version", "0.1")
|
||||
bearerAuth(apiKey)
|
||||
setBody(
|
||||
CreateObjectRequest(
|
||||
name = title,
|
||||
body = details,
|
||||
body = details?.takeIf { it.isNotBlank() },
|
||||
typeKey = typeKey,
|
||||
templateId = templateId,
|
||||
properties = properties,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ import kotlinx.serialization.json.JsonElement
|
||||
@Serializable
|
||||
data class CreateObjectRequest(
|
||||
val name: String,
|
||||
val body: String,
|
||||
val body: String? = null,
|
||||
@SerialName("type_key") val typeKey: String,
|
||||
@SerialName("template_id") val templateId: String? = null,
|
||||
val properties: List<JsonElement> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package ch.ergon.anytask.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Tag(
|
||||
val id: String,
|
||||
val key: String = "",
|
||||
val name: String,
|
||||
val color: String = "",
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package ch.ergon.anytask.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TagsResponse(
|
||||
val data: List<Tag>,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package ch.ergon.anytask.api.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Template(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val archived: Boolean = false,
|
||||
val icon: Icon? = null,
|
||||
val layout: String = "",
|
||||
val `object`: String = "",
|
||||
val properties: List<Property> = emptyList(),
|
||||
val snippet: String = "",
|
||||
@SerialName("space_id") val spaceId: String = "",
|
||||
val type: ObjectType? = null,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package ch.ergon.anytask.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TemplatesResponse(
|
||||
val data: List<Template>,
|
||||
)
|
||||
@@ -17,10 +17,10 @@ data class Connection(
|
||||
@Serializable
|
||||
data class PropertyConfig(
|
||||
val key: String,
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val format: String = "",
|
||||
val indicator: String = "",
|
||||
@SerialName("possible_values") val possibleValues: List<String> = emptyList(),
|
||||
@SerialName("object_type_key") val objectTypeKey: String = "",
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ data class Configuration(
|
||||
val connection: Connection = Connection(),
|
||||
val spaceId: String = "",
|
||||
@SerialName("task_type_key") val taskTypeKey: String = "",
|
||||
@SerialName("template_id") val templateId: String = "",
|
||||
val properties: List<PropertyConfig> = emptyList(),
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ import java.util.logging.Logger
|
||||
*/
|
||||
object HotkeyManager {
|
||||
|
||||
/** Human-readable description of the hotkey combination handled by [isHotkey]. */
|
||||
const val DESCRIPTION: String = "Ctrl+Shift+Space"
|
||||
|
||||
private val logger = LogManager.getLogger(HotkeyManager::class.java)
|
||||
private var listener: NativeKeyListener? = null
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -40,12 +41,15 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ch.ergon.anytask.api.AnyTypeApi
|
||||
import ch.ergon.anytask.api.dto.ObjectType
|
||||
import ch.ergon.anytask.api.dto.Property
|
||||
import ch.ergon.anytask.api.dto.Space
|
||||
import ch.ergon.anytask.api.dto.Template
|
||||
import ch.ergon.anytask.config.Configuration
|
||||
import ch.ergon.anytask.config.Connection
|
||||
import ch.ergon.anytask.config.PropertyConfig
|
||||
@@ -54,39 +58,95 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun SettingsWindow(
|
||||
config: Configuration,
|
||||
client: AnyTypeApi,
|
||||
onSave: (Configuration) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var apiKey by remember { mutableStateOf(config.connection.apiKey) }
|
||||
var loadedApiKey by remember { mutableStateOf(config.connection.apiKey) }
|
||||
var selectedSpaceId by remember { mutableStateOf(config.spaceId) }
|
||||
var selectedTypeKey by remember { mutableStateOf(config.taskTypeKey) }
|
||||
var selectedTemplateId by remember { mutableStateOf(config.templateId) }
|
||||
val propertyConfigs = remember { mutableStateListOf<PropertyConfig>().also { it.addAll(config.properties) } }
|
||||
|
||||
var spaces by remember { mutableStateOf<List<Space>>(emptyList()) }
|
||||
var isLoadingSpaces by remember { mutableStateOf(false) }
|
||||
var types by remember { mutableStateOf<List<ObjectType>>(emptyList()) }
|
||||
var isLoadingTypes by remember { mutableStateOf(false) }
|
||||
var templates by remember { mutableStateOf<List<Template>>(emptyList()) }
|
||||
var isLoadingTemplates by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Property edit state
|
||||
var editingIndex by remember { mutableStateOf<Int?>(null) } // null=none, -1=new
|
||||
|
||||
// Load types on start
|
||||
LaunchedEffect(Unit) {
|
||||
suspend fun loadSpaces(key: String) {
|
||||
spaces = emptyList()
|
||||
isLoadingSpaces = true
|
||||
val tempClient = AnyTypeApi(baseUrl = config.connection.baseUrl, apiKey = key)
|
||||
try {
|
||||
tempClient.getSpaces()
|
||||
.onSuccess { spaces = it }
|
||||
.onFailure { errorMessage = "Failed to load spaces: ${it.message}" }
|
||||
} finally {
|
||||
tempClient.close()
|
||||
}
|
||||
isLoadingSpaces = false
|
||||
}
|
||||
|
||||
suspend fun loadTypes(key: String, spaceId: String) {
|
||||
types = emptyList()
|
||||
isLoadingTypes = true
|
||||
client.getTypes()
|
||||
.onSuccess { types = it }
|
||||
.onFailure { errorMessage = "Failed to load types: ${it.message}" }
|
||||
val tempClient = AnyTypeApi(baseUrl = config.connection.baseUrl, apiKey = key, spaceId = spaceId)
|
||||
try {
|
||||
tempClient.getTypes()
|
||||
.onSuccess { types = it }
|
||||
.onFailure { errorMessage = "Failed to load types: ${it.message}" }
|
||||
} finally {
|
||||
tempClient.close()
|
||||
}
|
||||
isLoadingTypes = false
|
||||
}
|
||||
|
||||
suspend fun loadTemplates(key: String, spaceId: String, typeId: String) {
|
||||
templates = emptyList()
|
||||
isLoadingTemplates = true
|
||||
val tempClient = AnyTypeApi(baseUrl = config.connection.baseUrl, apiKey = key, spaceId = spaceId)
|
||||
try {
|
||||
tempClient.getTemplates(typeId)
|
||||
.onSuccess { templates = it }
|
||||
.onFailure { errorMessage = "Failed to load templates: ${it.message}" }
|
||||
} finally {
|
||||
tempClient.close()
|
||||
}
|
||||
isLoadingTemplates = false
|
||||
}
|
||||
|
||||
// Initial load
|
||||
LaunchedEffect(Unit) {
|
||||
loadSpaces(apiKey)
|
||||
if (selectedSpaceId.isNotEmpty()) {
|
||||
loadTypes(apiKey, selectedSpaceId)
|
||||
}
|
||||
}
|
||||
|
||||
// Load templates when type changes
|
||||
LaunchedEffect(selectedTypeKey, types) {
|
||||
val type = types.find { it.key == selectedTypeKey }
|
||||
if (type != null && selectedSpaceId.isNotEmpty()) {
|
||||
loadTemplates(apiKey, selectedSpaceId, type.id)
|
||||
}
|
||||
}
|
||||
|
||||
val selectedType = types.find { it.key == selectedTypeKey }
|
||||
val availableProperties = selectedType?.properties.orEmpty()
|
||||
|
||||
fun buildConfig(): Configuration = Configuration(
|
||||
connection = Connection(apiKey = apiKey, baseUrl = config.connection.baseUrl),
|
||||
spaceId = config.spaceId,
|
||||
spaceId = selectedSpaceId,
|
||||
taskTypeKey = selectedTypeKey,
|
||||
templateId = selectedTemplateId,
|
||||
properties = propertyConfigs.toList(),
|
||||
)
|
||||
|
||||
@@ -96,13 +156,62 @@ fun SettingsWindow(
|
||||
Text("Settings", style = MaterialTheme.typography.h6)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// ApiKey
|
||||
OutlinedTextField(
|
||||
value = apiKey,
|
||||
onValueChange = { apiKey = it },
|
||||
label = { Text("API Key") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
// ApiKey + reload button
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = apiKey,
|
||||
onValueChange = { apiKey = it },
|
||||
label = { Text("API Key") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f).onFocusChanged { focusState ->
|
||||
if (!focusState.isFocused && apiKey != loadedApiKey) {
|
||||
scope.launch {
|
||||
loadedApiKey = apiKey
|
||||
selectedSpaceId = ""
|
||||
selectedTypeKey = ""
|
||||
selectedTemplateId = ""
|
||||
templates = emptyList()
|
||||
propertyConfigs.clear()
|
||||
editingIndex = null
|
||||
types = emptyList()
|
||||
loadSpaces(apiKey)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
loadedApiKey = apiKey
|
||||
selectedSpaceId = ""
|
||||
selectedTypeKey = ""
|
||||
selectedTemplateId = ""
|
||||
templates = emptyList()
|
||||
propertyConfigs.clear()
|
||||
editingIndex = null
|
||||
types = emptyList()
|
||||
loadSpaces(apiKey)
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Reload spaces")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Space dropdown
|
||||
DropdownSelector(
|
||||
label = "Space",
|
||||
selectedValue = spaces.find { it.id == selectedSpaceId }?.name ?: selectedSpaceId,
|
||||
options = spaces.map { it.id to it.name },
|
||||
onSelect = { id ->
|
||||
selectedSpaceId = id
|
||||
selectedTypeKey = ""
|
||||
selectedTemplateId = ""
|
||||
templates = emptyList()
|
||||
propertyConfigs.clear()
|
||||
editingIndex = null
|
||||
scope.launch { loadTypes(apiKey, id) }
|
||||
},
|
||||
isLoading = isLoadingSpaces,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@@ -113,12 +222,26 @@ fun SettingsWindow(
|
||||
options = types.map { it.key to it.name },
|
||||
onSelect = { key ->
|
||||
selectedTypeKey = key
|
||||
selectedTemplateId = ""
|
||||
templates = emptyList()
|
||||
propertyConfigs.clear()
|
||||
editingIndex = null
|
||||
},
|
||||
isLoading = isLoadingTypes,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Template dropdown
|
||||
if (selectedTypeKey.isNotEmpty()) {
|
||||
DropdownSelector(
|
||||
label = "Template",
|
||||
selectedValue = templates.find { it.id == selectedTemplateId }?.name ?: selectedTemplateId,
|
||||
options = listOf("" to "(none)") + templates.map { it.id to it.name },
|
||||
onSelect = { id -> selectedTemplateId = id },
|
||||
isLoading = isLoadingTemplates,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Properties section
|
||||
Text("Properties", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
|
||||
@@ -241,7 +364,6 @@ private fun PropertyTable(
|
||||
Text("Name", fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f))
|
||||
Text("Type", fontWeight = FontWeight.Bold, modifier = Modifier.weight(1.5f))
|
||||
Text("Ind.", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.7f))
|
||||
Text("Values", fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f))
|
||||
Text("Obj Type", fontWeight = FontWeight.Bold, modifier = Modifier.weight(1.5f))
|
||||
Spacer(modifier = Modifier.width(40.dp))
|
||||
}
|
||||
@@ -260,11 +382,6 @@ private fun PropertyTable(
|
||||
Text(prop.name, modifier = Modifier.weight(2f), maxLines = 1)
|
||||
Text(prop.format, modifier = Modifier.weight(1.5f), maxLines = 1)
|
||||
Text(prop.indicator, modifier = Modifier.weight(0.7f), maxLines = 1)
|
||||
Text(
|
||||
prop.possibleValues.joinToString(", "),
|
||||
modifier = Modifier.weight(2f),
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(prop.objectTypeKey, modifier = Modifier.weight(1.5f), maxLines = 1)
|
||||
IconButton(onClick = { onDelete(index) }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete", tint = Color.Gray)
|
||||
@@ -286,7 +403,6 @@ private fun PropertyEditPanel(
|
||||
) {
|
||||
var selectedPropertyKey by remember(existing) { mutableStateOf(existing?.key ?: "") }
|
||||
var indicator by remember(existing) { mutableStateOf(existing?.indicator ?: "") }
|
||||
var possibleValuesText by remember(existing) { mutableStateOf(existing?.possibleValues?.joinToString("\n") ?: "") }
|
||||
var objectTypeKey by remember(existing) { mutableStateOf(existing?.objectTypeKey ?: "") }
|
||||
|
||||
val selectedProperty = availableProperties.find { it.key == selectedPropertyKey }
|
||||
@@ -323,15 +439,6 @@ private fun PropertyEditPanel(
|
||||
|
||||
// Format-specific fields
|
||||
when (format) {
|
||||
"select", "multi_select" -> {
|
||||
OutlinedTextField(
|
||||
value = possibleValuesText,
|
||||
onValueChange = { possibleValuesText = it },
|
||||
label = { Text("Possible Values (one per line)") },
|
||||
modifier = Modifier.fillMaxWidth().height(120.dp),
|
||||
maxLines = 10,
|
||||
)
|
||||
}
|
||||
"object", "objects" -> {
|
||||
DropdownSelector(
|
||||
label = "Object Type",
|
||||
@@ -347,14 +454,13 @@ private fun PropertyEditPanel(
|
||||
TextButton(onClick = onCancel) { Text("Cancel") }
|
||||
Button(
|
||||
onClick = {
|
||||
val values = possibleValuesText.lines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
onConfirm(
|
||||
PropertyConfig(
|
||||
key = selectedPropertyKey,
|
||||
id = selectedProperty?.id ?: existing?.id ?: "",
|
||||
name = selectedProperty?.name ?: existing?.name ?: "",
|
||||
format = format,
|
||||
indicator = indicator,
|
||||
possibleValues = values,
|
||||
objectTypeKey = objectTypeKey,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ch.ergon.anytask.api.AnyTypeApi
|
||||
import ch.ergon.anytask.api.dto.SearchObject
|
||||
import ch.ergon.anytask.api.dto.Tag
|
||||
import ch.ergon.anytask.config.Configuration
|
||||
import ch.ergon.anytask.api.dto.buildPropertyValue
|
||||
import ch.ergon.anytask.config.PropertyConfig
|
||||
@@ -62,6 +63,7 @@ fun TaskWindow(client: AnyTypeApi, config: Configuration, isVisible: Boolean, on
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val propertyValues = remember { mutableStateMapOf<String, String>() }
|
||||
val propertyTags = remember { mutableStateMapOf<String, List<Tag>>() }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
@@ -79,6 +81,7 @@ fun TaskWindow(client: AnyTypeApi, config: Configuration, isVisible: Boolean, on
|
||||
title = title.trim(),
|
||||
details = details.trim(),
|
||||
typeKey = typeKey,
|
||||
templateId = config.templateId.ifEmpty { null },
|
||||
properties = propElements,
|
||||
)
|
||||
.onSuccess {
|
||||
@@ -143,6 +146,7 @@ fun TaskWindow(client: AnyTypeApi, config: Configuration, isVisible: Boolean, on
|
||||
value = propertyValues[prop.key] ?: "",
|
||||
onValueChange = { propertyValues[prop.key] = it },
|
||||
client = client,
|
||||
tags = propertyTags[prop.key] ?: emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -173,7 +177,19 @@ fun TaskWindow(client: AnyTypeApi, config: Configuration, isVisible: Boolean, on
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isVisible) { if (isVisible) focusRequester.requestFocus() }
|
||||
LaunchedEffect(isVisible) {
|
||||
if (isVisible) {
|
||||
focusRequester.requestFocus()
|
||||
config.properties
|
||||
.filter { it.format in listOf("select", "multi_select") && it.id.isNotEmpty() }
|
||||
.forEach { prop ->
|
||||
launch {
|
||||
client.getTags(prop.id)
|
||||
.onSuccess { propertyTags[prop.key] = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -182,13 +198,14 @@ private fun PropertyField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
client: AnyTypeApi,
|
||||
tags: List<Tag> = emptyList(),
|
||||
) {
|
||||
when (prop.format) {
|
||||
"select", "multi_select" -> {
|
||||
SelectField(
|
||||
label = prop.name,
|
||||
value = value,
|
||||
options = prop.possibleValues,
|
||||
tags = tags,
|
||||
onValueChange = onValueChange,
|
||||
)
|
||||
}
|
||||
@@ -230,16 +247,17 @@ private fun PropertyField(
|
||||
private fun SelectField(
|
||||
label: String,
|
||||
value: String,
|
||||
options: List<String>,
|
||||
tags: List<Tag>,
|
||||
onValueChange: (String) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedName = tags.find { it.id == value }?.name ?: value
|
||||
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.caption)
|
||||
Box {
|
||||
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(value.ifEmpty { "Select..." })
|
||||
Text(selectedName.ifEmpty { "Select..." })
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(onClick = {
|
||||
@@ -248,12 +266,12 @@ private fun SelectField(
|
||||
}) {
|
||||
Text("(none)", style = MaterialTheme.typography.caption)
|
||||
}
|
||||
options.forEach { option ->
|
||||
tags.forEach { tag ->
|
||||
DropdownMenuItem(onClick = {
|
||||
onValueChange(option)
|
||||
onValueChange(tag.id)
|
||||
expanded = false
|
||||
}) {
|
||||
Text(option)
|
||||
Text(tag.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Configuration status="WARN">
|
||||
<Properties>
|
||||
<Property name="CLR">{FATAL=bright red, ERROR=red, WARN=bright yellow, INFO=Normal, DEBUG=white, TRACE=white}</Property>
|
||||
<Property name="MSG_CLR">{FATAL=bright red, ERROR=Normal, WARN=Normal, INFO=Normal, DEBUG=Normal, TRACE=white}</Property>
|
||||
<Property name="LOG_PATTERN">%d{yy-MM-dd HH:mm:ss.SSS} %style{[%t]}{magenta} %highlight{%-5level}${CLR} %style{%logger{36}}{cyan} %highlight{: %msg%n}${MSG_CLR}</Property>
|
||||
</Properties>
|
||||
<Appenders>
|
||||
<Console name="Console" target="SYSTEM_OUT">
|
||||
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
|
||||
<PatternLayout pattern="${LOG_PATTERN}" disableAnsi="false"/>
|
||||
</Console>
|
||||
</Appenders>
|
||||
<Loggers>
|
||||
<Root level="info">
|
||||
<Root level="trace">
|
||||
<AppenderRef ref="Console"/>
|
||||
</Root>
|
||||
</Loggers>
|
||||
|
||||