diff --git a/.gitignore b/.gitignore
index 0795f55..d534b33 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@ out/
# macOS
.DS_Store
+
+# Http Client
+httpClient/http-client.private.env.json
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..f9163b4
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..360d66b
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 159fe23..2758df8 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,6 +1,10 @@
-
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
index b205e4b..4ed22e1 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -2,7 +2,7 @@
-
+
\ No newline at end of file
diff --git a/.idea/modules/AnyTask.main.iml b/.idea/modules/AnyTask.main.iml
new file mode 100644
index 0000000..fb979ed
--- /dev/null
+++ b/.idea/modules/AnyTask.main.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 13685d4..2b8c39d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,37 +2,47 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+## Git
+
+Always stage newly created files that permanently belong to the project with `git add `.
+
## Commands
```bash
-./gradlew run # App starten
-./gradlew compileKotlin # Nur kompilieren
-./gradlew packageDmg # macOS DMG bauen
+./gradlew run # Run the app
+./gradlew compileKotlin # Compile only
+./gradlew packageDmg # Build macOS DMG
```
## Architecture
-**AnyTask** ist eine Kotlin/Compose-Desktop-App, die im Hintergrund läuft und über eine globale Tastenkombination (Ctrl+Shift+Space) ein Fenster zum Erstellen von AnyType-Tasks zeigt.
+**AnyTask** is a Kotlin/Compose desktop app that runs in the background and shows a task creation window via a global hotkey (Ctrl+Shift+Space).
-### Komponenten
+### Components
-- **`Main.kt`** — Einstiegspunkt. Setzt `apple.awt.UIElement=true` (kein Dock-Icon), startet die Compose-`application {}`, richtet System-Tray und HotkeyManager ein.
-- **`hotkey/HotkeyManager.kt`** — JNativeHook-Wrapper. Registriert Ctrl+Shift+Space als systemweiten Hotkey und dispatcht den Callback auf den AWT-EDT.
-- **`ui/TaskWindow.kt`** — Compose-UI mit Formular (Titel, Details). Escape schliesst, ⌘↵ erstellt.
-- **`api/AnyTypeClient.kt`** — Ktor-HTTP-Client für die lokale AnyType-API (`localhost:31007`). Momentan Skeleton — API-Details müssen noch verifiziert werden.
+- **`Main.kt`** — Entry point. Sets `apple.awt.UIElement=true` (no dock icon), starts the Compose `application {}`, sets up system tray and HotkeyManager.
+- **`hotkey/HotkeyManager.kt`** — JNativeHook wrapper. Registers Ctrl+Shift+Space as a system-wide hotkey and dispatches the callback on the AWT EDT.
+- **`ui/TaskWindow.kt`** — Compose UI with a form (title, details). Escape closes, ⌘↵ creates.
+- **`api/AnyTypeApi.kt`** — Ktor HTTP client for the local AnyType API (`localhost:31009`). Currently a skeleton — API details still need to be verified.
### Stack
- Kotlin 2.1.0 / Compose Multiplatform 1.7.1
-- JNativeHook 2.2.2 (globale Hotkeys)
+- JNativeHook 2.2.2 (global hotkeys)
- Ktor 3.0.0 + kotlinx.serialization (AnyType API)
- Gradle 9.2.1
-## macOS-Hinweise
+## Code Style
-- **Accessibility-Berechtigung** für JNativeHook: System Settings → Privacy & Security → Accessibility → App hinzufügen. Ohne diese Berechtigung funktioniert der globale Hotkey nicht.
-- Das Tray-Icon erscheint in der Menüleiste (kein Dock-Icon, da `apple.awt.UIElement=true`).
+- One class per file — file name matches the class name.
+- No wildcard imports (`import foo.*`).
+- Code, comments, and documentation in English.
+
+## macOS Notes
+
+- **Accessibility permission** for JNativeHook: System Settings → Privacy & Security → Accessibility → add the app. Without this permission the global hotkey will not work.
+- The tray icon appears in the menu bar (no dock icon, since `apple.awt.UIElement=true`).
## AnyType API
-Die lokale AnyType-API läuft via `anytype-heart` auf `http://localhost:31007`. Der `appKey` und die `spaceId` in `AnyTypeClient` müssen noch konfigurierbar gemacht werden.
+The local AnyType API runs via `anytype-heart` on `http://localhost:31007`. `apiKey` and `spaceId` are configured via `anytask.yaml`.
diff --git a/build.gradle.kts b/build.gradle.kts
index 2ce0b24..62a6c24 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -28,6 +28,13 @@ dependencies {
// Coroutines (Swing/AWT dispatcher)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.9.0")
+
+ // YAML config
+ implementation("com.charleskorn.kaml:kaml:0.67.0")
+
+ // Logging
+ implementation("org.apache.logging.log4j:log4j-api:2.24.3")
+ implementation("org.apache.logging.log4j:log4j-core:2.24.3")
}
compose.desktop {
diff --git a/httpClient/anyType.http b/httpClient/anyType.http
new file mode 100644
index 0000000..dbe5a2c
--- /dev/null
+++ b/httpClient/anyType.http
@@ -0,0 +1,14 @@
+### Get Spaces
+GET http://localhost:31009/v1/spaces
+Authorization: Bearer {{apiKey}}
+
+### Create Task
+POST http://localhost:31009/v1/spaces/{{spaceId}}/objects
+Authorization: Bearer {{apiKey}}
+Content-Type: application/json
+
+{
+ "name": "Test Task II",
+ "body": "Created via HTTP client",
+ "type_key": "ot-task"
+}
diff --git a/httpClient/http-client.private.env.template.json b/httpClient/http-client.private.env.template.json
new file mode 100644
index 0000000..2b32c82
--- /dev/null
+++ b/httpClient/http-client.private.env.template.json
@@ -0,0 +1,6 @@
+{
+ "local": {
+ "apiKey": "",
+ "spaceId": ""
+ }
+}
diff --git a/src/main/kotlin/ch/ergon/anytask/Main.kt b/src/main/kotlin/ch/ergon/anytask/Main.kt
index e7b4193..08e3b66 100644
--- a/src/main/kotlin/ch/ergon/anytask/Main.kt
+++ b/src/main/kotlin/ch/ergon/anytask/Main.kt
@@ -5,13 +5,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
-import ch.ergon.anytask.api.AnyTypeClient
+import ch.ergon.anytask.api.AnyTypeApi
+import ch.ergon.anytask.config.loadConfig
import ch.ergon.anytask.hotkey.HotkeyManager
import ch.ergon.anytask.ui.TaskWindow
+import org.apache.logging.log4j.LogManager
import java.awt.*
+import kotlin.system.exitProcess
+
+private val logger = LogManager.getLogger("Main")
fun main() {
- // Kein Dock-Icon auf macOS (Agent-App)
+ // No dock icon on macOS (agent app)
System.setProperty("apple.awt.UIElement", "true")
application {
@@ -22,11 +27,25 @@ fun main() {
size = DpSize(480.dp, 320.dp)
)
- val client = remember { AnyTypeClient() }
+ val config = remember { loadConfig() }
+ val client = remember { AnyTypeApi(apiKey = config.apiKey, spaceId = config.spaceId) }
LaunchedEffect(Unit) {
HotkeyManager.start(onHotkey = { isVisible = !isVisible })
setupSystemTray(onShow = { isVisible = 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 == config.spaceId }
+ if (space == null) {
+ logger.error("Selected space not found: ${config.spaceId}")
+ exitProcess(-1)
+ } else {
+ logger.info("Selected space: ${space.name} (${space.id})")
+ }
+ }
+ .onFailure { logger.error("Failed to load spaces: ${it.message}") }
}
DisposableEffect(Unit) {
@@ -57,14 +76,14 @@ private fun setupSystemTray(onShow: () -> Unit, onExit: () -> Unit) {
val icon = createTrayIcon(size)
val menu = PopupMenu().apply {
- add(MenuItem("Öffnen").also { it.addActionListener { onShow() } })
+ add(MenuItem("Open").also { it.addActionListener { onShow() } })
addSeparator()
- add(MenuItem("Beenden").also { it.addActionListener { onExit() } })
+ add(MenuItem("Quit").also { it.addActionListener { onExit() } })
}
val trayIcon = TrayIcon(icon, "AnyTask", menu).apply {
isImageAutoSize = true
- addActionListener { onShow() } // Doppelklick
+ addActionListener { onShow() } // double click
}
SystemTray.getSystemTray().add(trayIcon)
diff --git a/src/main/kotlin/ch/ergon/anytask/api/AnyTypeApi.kt b/src/main/kotlin/ch/ergon/anytask/api/AnyTypeApi.kt
new file mode 100644
index 0000000..c62f528
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/anytask/api/AnyTypeApi.kt
@@ -0,0 +1,75 @@
+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.Space
+import ch.ergon.anytask.api.dto.SpacesResponse
+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.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.serialization.kotlinx.json.*
+import kotlinx.serialization.json.Json
+import org.apache.logging.log4j.LogManager
+import kotlin.time.DurationUnit.MILLISECONDS
+import kotlin.time.toDuration
+
+/**
+ * Client for the local AnyType API (anytype-heart middleware).
+ * The API runs on http://localhost:31007 by default.
+ * apiKey and spaceId are loaded from anytask.yaml.
+ */
+class AnyTypeApi(
+ private val baseUrl: String = "http://localhost:31009",
+ private val apiKey: String = "",
+ private val spaceId: String = "",
+) {
+ private val logger = LogManager.getLogger(AnyTypeApi::class.java)
+
+ private val http = HttpClient(CIO) {
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ }.also { client ->
+ client.plugin(HttpSend).intercept { request ->
+ val method = request.method.value
+ val url = request.url.buildString()
+ logger.info("→ $method $url")
+ val start = System.currentTimeMillis()
+ val call = execute(request)
+ val duration = (System.currentTimeMillis() - start).toDuration(MILLISECONDS)
+ val status = call.response.status
+ logger.info("← ${status.value} (${status.description}) $duration")
+ if (!status.isSuccess()) {
+ logger.error("Response body: ${call.response.bodyAsText()}")
+ }
+ call
+ }
+ }
+
+ suspend fun getSpaces(): Result> =
+ runCatching {
+ val response = http.get("$baseUrl/v1/spaces") {
+ header("Anytype-Version", "0.1")
+ bearerAuth(apiKey)
+ }
+ response.body().data
+ }
+
+ suspend fun createTask(title: String, details: String = ""): Result =
+ 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, typeKey = "ot-task"))
+ }
+ response.body().objectId
+ }
+
+ fun close() = http.close()
+}
diff --git a/src/main/kotlin/ch/ergon/anytask/api/AnyTypeClient.kt b/src/main/kotlin/ch/ergon/anytask/api/AnyTypeClient.kt
deleted file mode 100644
index 138418f..0000000
--- a/src/main/kotlin/ch/ergon/anytask/api/AnyTypeClient.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package ch.ergon.anytask.api
-
-import io.ktor.client.*
-import io.ktor.client.call.*
-import io.ktor.client.engine.cio.*
-import io.ktor.client.plugins.contentnegotiation.*
-import io.ktor.client.request.*
-import io.ktor.http.*
-import io.ktor.serialization.kotlinx.json.*
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.Json
-
-/**
- * Client für die lokale AnyType-API (anytype-heart Middleware).
- * Die API läuft standardmässig auf http://localhost:31007.
- *
- * TODO: App-Key via Einstellungen konfigurierbar machen.
- */
-class AnyTypeClient(
- private val baseUrl: String = "http://localhost:31007",
- private val appKey: String = "", // wird nach erstem Login gesetzt
-) {
- private val http = HttpClient(CIO) {
- install(ContentNegotiation) {
- json(Json { ignoreUnknownKeys = true })
- }
- }
-
- suspend fun createTask(title: String, details: String = "", spaceId: String = ""): Result =
- runCatching {
- val response = http.post("$baseUrl/v1/spaces/$spaceId/objects") {
- contentType(ContentType.Application.Json)
- header("Anytype-Version", "0.1")
- bearerAuth(appKey)
- setBody(CreateObjectRequest(title = title, details = details, typeKey = "ot-task"))
- }
- response.body().objectId
- }
-
- fun close() = http.close()
-}
-
-@Serializable
-private data class CreateObjectRequest(
- val title: String,
- val details: String,
- val typeKey: String,
-)
-
-@Serializable
-private data class CreateObjectResponse(
- val objectId: String,
-)
diff --git a/src/main/kotlin/ch/ergon/anytask/api/dto/CreateObjectRequest.kt b/src/main/kotlin/ch/ergon/anytask/api/dto/CreateObjectRequest.kt
new file mode 100644
index 0000000..aba55ce
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/anytask/api/dto/CreateObjectRequest.kt
@@ -0,0 +1,11 @@
+package ch.ergon.anytask.api.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CreateObjectRequest(
+ val name: String,
+ val body: String,
+ @SerialName("type_key") val typeKey: String,
+)
\ No newline at end of file
diff --git a/src/main/kotlin/ch/ergon/anytask/api/dto/CreateObjectResponse.kt b/src/main/kotlin/ch/ergon/anytask/api/dto/CreateObjectResponse.kt
new file mode 100644
index 0000000..c0128fd
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/anytask/api/dto/CreateObjectResponse.kt
@@ -0,0 +1,8 @@
+package ch.ergon.anytask.api.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CreateObjectResponse(
+ val objectId: String,
+)
\ No newline at end of file
diff --git a/src/main/kotlin/ch/ergon/anytask/api/dto/Space.kt b/src/main/kotlin/ch/ergon/anytask/api/dto/Space.kt
new file mode 100644
index 0000000..e2b42c3
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/anytask/api/dto/Space.kt
@@ -0,0 +1,9 @@
+package ch.ergon.anytask.api.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Space(
+ val id: String,
+ val name: String,
+)
\ No newline at end of file
diff --git a/src/main/kotlin/ch/ergon/anytask/api/dto/SpacesResponse.kt b/src/main/kotlin/ch/ergon/anytask/api/dto/SpacesResponse.kt
new file mode 100644
index 0000000..54a1ce3
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/anytask/api/dto/SpacesResponse.kt
@@ -0,0 +1,8 @@
+package ch.ergon.anytask.api.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SpacesResponse(
+ val data: List,
+)
\ No newline at end of file
diff --git a/src/main/kotlin/ch/ergon/anytask/config/AppConfig.kt b/src/main/kotlin/ch/ergon/anytask/config/AppConfig.kt
new file mode 100644
index 0000000..9939595
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/anytask/config/AppConfig.kt
@@ -0,0 +1,32 @@
+package ch.ergon.anytask.config
+
+import com.charleskorn.kaml.Yaml
+import kotlinx.serialization.Serializable
+import java.io.File
+
+@Serializable
+data class AppConfig(
+ val apiKey: String = "",
+ val spaceId: String = "",
+)
+
+fun loadConfig(): AppConfig {
+ val yaml = Yaml.default
+ val serializer = AppConfig.serializer()
+
+ // External files take precedence (production use)
+ val externalLocations = listOf(
+ File(System.getProperty("user.home"), ".anytask/anytask.yaml"),
+ File("anytask.yaml"),
+ )
+ externalLocations.firstOrNull { it.exists() }?.let {
+ return yaml.decodeFromString(serializer, it.readText())
+ }
+
+ // Fallback: classpath resource (development)
+ AppConfig::class.java.getResourceAsStream("/anytask.yaml")?.let {
+ return yaml.decodeFromString(serializer, it.bufferedReader().readText())
+ }
+
+ return AppConfig()
+}
diff --git a/src/main/kotlin/ch/ergon/anytask/hotkey/HotkeyManager.kt b/src/main/kotlin/ch/ergon/anytask/hotkey/HotkeyManager.kt
index 8f0bc20..0643d7f 100644
--- a/src/main/kotlin/ch/ergon/anytask/hotkey/HotkeyManager.kt
+++ b/src/main/kotlin/ch/ergon/anytask/hotkey/HotkeyManager.kt
@@ -4,20 +4,22 @@ import com.github.kwhat.jnativehook.GlobalScreen
import com.github.kwhat.jnativehook.NativeHookException
import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent
import com.github.kwhat.jnativehook.keyboard.NativeKeyListener
+import org.apache.logging.log4j.LogManager
import java.awt.EventQueue
import java.util.logging.Level
import java.util.logging.Logger
/**
- * Lauscht auf eine systemweite Tastenkombination: Ctrl+Shift+Space
- * Erfordert auf macOS die Berechtigung "Eingabegeräte überwachen" (Accessibility).
+ * Listens for a system-wide hotkey: Ctrl+Shift+Space.
+ * Requires the Accessibility permission on macOS.
*/
object HotkeyManager {
+ private val logger = LogManager.getLogger(HotkeyManager::class.java)
private var listener: NativeKeyListener? = null
fun start(onHotkey: () -> Unit) {
- // JNativeHook-Logging unterdrücken
+ // Suppress JNativeHook logging
Logger.getLogger(GlobalScreen::class.java.`package`.name).apply {
level = Level.OFF
useParentHandlers = false
@@ -26,14 +28,14 @@ object HotkeyManager {
try {
GlobalScreen.registerNativeHook()
} catch (e: NativeHookException) {
- System.err.println("JNativeHook konnte nicht registriert werden: ${e.message}")
+ logger.error("Failed to register JNativeHook: ${e.message}")
return
}
listener = object : NativeKeyListener {
override fun nativeKeyPressed(e: NativeKeyEvent) {
if (isHotkey(e)) {
- // Callback auf den AWT-EDT dispatchen (Compose läuft dort)
+ // Dispatch callback on the AWT EDT (where Compose runs)
EventQueue.invokeLater(onHotkey)
}
}
diff --git a/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt b/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt
index a153840..c7c02aa 100644
--- a/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt
+++ b/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt
@@ -9,11 +9,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.dp
-import ch.ergon.anytask.api.AnyTypeClient
+import ch.ergon.anytask.api.AnyTypeApi
import kotlinx.coroutines.launch
@Composable
-fun TaskWindow(client: AnyTypeClient, onDismiss: () -> Unit) {
+fun TaskWindow(client: AnyTypeApi, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
var title by remember { mutableStateOf("") }
var details by remember { mutableStateOf("") }
@@ -58,12 +58,12 @@ fun TaskWindow(client: AnyTypeClient, onDismiss: () -> Unit) {
},
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
- Text("Neue Aufgabe", style = MaterialTheme.typography.h6)
+ Text("New Task", style = MaterialTheme.typography.h6)
OutlinedTextField(
value = title,
onValueChange = { title = it },
- label = { Text("Titel") },
+ label = { Text("Title") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
@@ -86,10 +86,10 @@ fun TaskWindow(client: AnyTypeClient, onDismiss: () -> Unit) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
- TextButton(onClick = onDismiss) { Text("Abbrechen") }
+ TextButton(onClick = onDismiss) { Text("Cancel") }
Button(onClick = ::submit, enabled = title.isNotBlank() && !isLoading) {
if (isLoading) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
- else Text("Erstellen ⌘↵")
+ else Text("Create ⌘↵")
}
}
}
diff --git a/src/main/resources/anytask.yaml b/src/main/resources/anytask.yaml
new file mode 100644
index 0000000..be13d17
--- /dev/null
+++ b/src/main/resources/anytask.yaml
@@ -0,0 +1,2 @@
+apiKey: 2tkMSzFegWwzsQ+n9SpGwVjychEkCESWjV1/ftytXG4=
+spaceId: bafyreiafzvfsrurvx4gvr2wwysgp5uugujzmbdg5hxnjlahfmx3ww6kxfy.172mmtcppmjtt
diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml
new file mode 100644
index 0000000..35a6a3c
--- /dev/null
+++ b/src/main/resources/log4j2.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file