From c8a0ba51bc53d87a2003a312e25e285fb908be16 Mon Sep 17 00:00:00 2001 From: tylxr59 <102394635+tylxr59@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:15:37 -0500 Subject: [PATCH 1/3] Added Nextcloud Notes support for notes and shopping lists --- .../org/stypox/dicio/eval/SkillHandler.kt | 2 + .../nextcloud_notes/NextcloudNotesInfo.kt | 139 ++++++++++++++++ .../nextcloud_notes/NextcloudNotesOutput.kt | 71 ++++++++ .../nextcloud_notes/NextcloudNotesSkill.kt | 152 ++++++++++++++++++ .../SkillSettingsNextcloudNotesSerializer.kt | 23 +++ .../skill_settings_nextcloud_notes.proto | 12 ++ app/src/main/res/values/strings.xml | 20 +++ app/src/main/sentences/en/nextcloud_notes.yml | 19 +++ app/src/main/sentences/skill_definitions.yml | 12 ++ 9 files changed, 450 insertions(+) create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesInfo.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesOutput.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesSkill.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/SkillSettingsNextcloudNotesSerializer.kt create mode 100644 app/src/main/proto/skill_settings_nextcloud_notes.proto create mode 100644 app/src/main/sentences/en/nextcloud_notes.yml diff --git a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt index 5c276aebc..06105ccf6 100644 --- a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt +++ b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt @@ -24,6 +24,7 @@ import org.stypox.dicio.skills.listening.ListeningInfo import org.stypox.dicio.skills.lyrics.LyricsInfo import org.stypox.dicio.skills.media.MediaInfo import org.stypox.dicio.skills.navigation.NavigationInfo +import org.stypox.dicio.skills.nextcloud_notes.NextcloudNotesInfo import org.stypox.dicio.skills.open.OpenInfo import org.stypox.dicio.skills.search.SearchInfo import org.stypox.dicio.skills.telephone.TelephoneInfo @@ -55,6 +56,7 @@ class SkillHandler @Inject constructor( JokeInfo, ListeningInfo(dataStore), TranslationInfo, + NextcloudNotesInfo, ) private val fallbackSkillInfoList = listOf( diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesInfo.kt b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesInfo.kt new file mode 100644 index 000000000..6ff1ab04a --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesInfo.kt @@ -0,0 +1,139 @@ +package org.stypox.dicio.skills.nextcloud_notes + +import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Note +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStore +import kotlinx.coroutines.launch +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.Skill +import org.dicio.skill.skill.SkillInfo +import org.stypox.dicio.R +import org.stypox.dicio.sentences.Sentences +import org.stypox.dicio.settings.ui.StringSetting + +object NextcloudNotesInfo : SkillInfo("nextcloud_notes") { + override fun name(context: Context) = + context.getString(R.string.skill_name_nextcloud_notes) + + override fun sentenceExample(context: Context) = + context.getString(R.string.skill_sentence_example_nextcloud_notes) + + @Composable + override fun icon() = + rememberVectorPainter(Icons.Default.Note) + + override fun isAvailable(ctx: SkillContext): Boolean { + return Sentences.NextcloudNotes[ctx.sentencesLanguage] != null + } + + override fun build(ctx: SkillContext): Skill<*> { + return NextcloudNotesSkill(NextcloudNotesInfo, Sentences.NextcloudNotes[ctx.sentencesLanguage]!!) + } + + // DataStore for Nextcloud Notes settings + internal val Context.nextcloudNotesDataStore by dataStore( + fileName = "skill_settings_nextcloud_notes.pb", + serializer = SkillSettingsNextcloudNotesSerializer, + corruptionHandler = ReplaceFileCorruptionHandler { + SkillSettingsNextcloudNotesSerializer.defaultValue + }, + ) + + override val renderSettings: @Composable () -> Unit get() = @Composable { + val dataStore = LocalContext.current.nextcloudNotesDataStore + val data by dataStore.data.collectAsState(SkillSettingsNextcloudNotesSerializer.defaultValue) + val scope = rememberCoroutineScope() + + Column { + StringSetting( + title = stringResource(R.string.pref_nextcloud_notes_server_address), + descriptionWhenEmpty = stringResource(R.string.pref_nextcloud_notes_server_address_hint), + ).Render( + value = data.serverAddress, + onValueChange = { serverAddress -> + scope.launch { + dataStore.updateData { settings -> + settings.toBuilder() + .setServerAddress(serverAddress) + .build() + } + } + }, + ) + + StringSetting( + title = stringResource(R.string.pref_nextcloud_notes_username), + descriptionWhenEmpty = stringResource(R.string.pref_nextcloud_notes_username_hint), + ).Render( + value = data.username, + onValueChange = { username -> + scope.launch { + dataStore.updateData { settings -> + settings.toBuilder() + .setUsername(username) + .build() + } + } + }, + ) + + StringSetting( + title = stringResource(R.string.pref_nextcloud_notes_password), + descriptionWhenEmpty = stringResource(R.string.pref_nextcloud_notes_password_hint), + ).Render( + value = data.password, + onValueChange = { password -> + scope.launch { + dataStore.updateData { settings -> + settings.toBuilder() + .setPassword(password) + .build() + } + } + }, + ) + + StringSetting( + title = stringResource(R.string.pref_nextcloud_notes_target_note), + descriptionWhenEmpty = stringResource(R.string.pref_nextcloud_notes_target_note_hint), + ).Render( + value = data.targetNote, + onValueChange = { targetNote -> + scope.launch { + dataStore.updateData { settings -> + settings.toBuilder() + .setTargetNote(targetNote) + .build() + } + } + }, + ) + + StringSetting( + title = stringResource(R.string.pref_nextcloud_notes_target_shopping_list), + descriptionWhenEmpty = stringResource(R.string.pref_nextcloud_notes_target_shopping_list_hint), + ).Render( + value = data.targetShoppingList, + onValueChange = { targetShoppingList -> + scope.launch { + dataStore.updateData { settings -> + settings.toBuilder() + .setTargetShoppingList(targetShoppingList) + .build() + } + } + }, + ) + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesOutput.kt new file mode 100644 index 000000000..d51198c66 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesOutput.kt @@ -0,0 +1,71 @@ +package org.stypox.dicio.skills.nextcloud_notes + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillOutput +import org.stypox.dicio.R +import org.stypox.dicio.io.graphical.HeadlineSpeechSkillOutput +import org.stypox.dicio.util.getString + +sealed interface NextcloudNotesOutput : SkillOutput { + data class Success( + val noteName: String, + val content: String, + val isShoppingList: Boolean, + ) : NextcloudNotesOutput, HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = if (isShoppingList) { + ctx.getString(R.string.skill_nextcloud_notes_added_to_shopping_list, content) + } else { + ctx.getString(R.string.skill_nextcloud_notes_note_added, content) + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column { + Text( + text = if (isShoppingList) { + stringResource(R.string.skill_nextcloud_notes_added_to_shopping_list, content) + } else { + stringResource(R.string.skill_nextcloud_notes_note_added, content) + }, + style = MaterialTheme.typography.bodyLarge, + modifier = androidx.compose.ui.Modifier.testTag("nextcloud_notes_success") + ) + Text( + text = stringResource(R.string.skill_nextcloud_notes_saved_to, noteName), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + data class Failed( + val reason: FailureReason, + val errorMessage: String? = null + ) : NextcloudNotesOutput, HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = when (reason) { + FailureReason.SETTINGS_MISSING -> ctx.getString(R.string.skill_nextcloud_notes_settings_missing) + FailureReason.TARGET_NOTE_MISSING -> ctx.getString(R.string.skill_nextcloud_notes_target_note_missing) + FailureReason.TARGET_SHOPPING_LIST_MISSING -> ctx.getString(R.string.skill_nextcloud_notes_target_shopping_list_missing) + FailureReason.CONTENT_EMPTY -> ctx.getString(R.string.skill_nextcloud_notes_content_empty) + FailureReason.CONNECTION_ERROR -> ctx.getString( + R.string.skill_nextcloud_notes_connection_error, + errorMessage ?: "Unknown error" + ) + } + } + + enum class FailureReason { + SETTINGS_MISSING, + TARGET_NOTE_MISSING, + TARGET_SHOPPING_LIST_MISSING, + CONTENT_EMPTY, + CONNECTION_ERROR + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesSkill.kt new file mode 100644 index 000000000..742b144c5 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesSkill.kt @@ -0,0 +1,152 @@ +package org.stypox.dicio.skills.nextcloud_notes + +import kotlinx.coroutines.flow.first +import okhttp3.Credentials +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillInfo +import org.dicio.skill.skill.SkillOutput +import org.dicio.skill.standard.StandardRecognizerData +import org.dicio.skill.standard.StandardRecognizerSkill +import org.stypox.dicio.sentences.Sentences.NextcloudNotes +import org.stypox.dicio.skills.nextcloud_notes.NextcloudNotesInfo.nextcloudNotesDataStore +import java.net.URLEncoder +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class NextcloudNotesSkill( + correspondingSkillInfo: SkillInfo, + data: StandardRecognizerData +) : StandardRecognizerSkill(correspondingSkillInfo, data) { + + override suspend fun generateOutput(ctx: SkillContext, inputData: NextcloudNotes): SkillOutput { + val prefs = ctx.android.nextcloudNotesDataStore.data.first() + + // Validate settings + if (prefs.serverAddress.isEmpty() || prefs.username.isEmpty() || prefs.password.isEmpty()) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.SETTINGS_MISSING + ) + } + + val (content, targetNote) = when (inputData) { + is NextcloudNotes.AddNote -> { + if (prefs.targetNote.isEmpty()) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.TARGET_NOTE_MISSING + ) + } + Pair(inputData.content ?: "", prefs.targetNote) + } + is NextcloudNotes.AddToShoppingList -> { + if (prefs.targetShoppingList.isEmpty()) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.TARGET_SHOPPING_LIST_MISSING + ) + } + Pair(inputData.item ?: "", prefs.targetShoppingList) + } + } + + if (content.isEmpty()) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.CONTENT_EMPTY + ) + } + + // Add note via WebDAV + return try { + addNoteViaWebDAV( + serverAddress = prefs.serverAddress, + username = prefs.username, + password = prefs.password, + noteName = targetNote, + content = content, + isShoppingList = inputData is NextcloudNotes.AddToShoppingList + ) + NextcloudNotesOutput.Success( + noteName = targetNote, + content = content, + isShoppingList = inputData is NextcloudNotes.AddToShoppingList + ) + } catch (e: Exception) { + NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.CONNECTION_ERROR, + errorMessage = e.message + ) + } + } + + private suspend fun addNoteViaWebDAV( + serverAddress: String, + username: String, + password: String, + noteName: String, + content: String, + isShoppingList: Boolean + ) { + val client = OkHttpClient() + + // Normalize server address (remove trailing slash) + val normalizedServer = serverAddress.trimEnd('/') + + // Encode note name for URL (Nextcloud Notes stores in /Notes/ folder) + // User provides the full filename with extension (e.g., "MyNote.md" or "Shopping.txt") + val encodedNoteName = URLEncoder.encode(noteName, "UTF-8") + + // Nextcloud stores notes in WebDAV files under the Notes folder + val noteUrl = "$normalizedServer/remote.php/dav/files/$username/Notes/$encodedNoteName" + + // Get existing content first + val getRequest = Request.Builder() + .url(noteUrl) + .header("Authorization", Credentials.basic(username, password)) + .get() + .build() + + val existingContent = try { + val response = client.newCall(getRequest).execute() + if (response.isSuccessful) { + response.body?.string() ?: "" + } else { + "" + } + } catch (e: Exception) { + "" + } + + // Append new content with timestamp + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + val newContent = if (isShoppingList) { + // For shopping lists, add as a checkbox item + if (existingContent.isEmpty()) { + "- [ ] $content" + } else { + "$existingContent\n- [ ] $content" + } + } else { + // For notes, add with timestamp + if (existingContent.isEmpty()) { + "[$timestamp] $content" + } else { + "$existingContent\n\n[$timestamp] $content" + } + } + + // Upload the updated content + val putRequest = Request.Builder() + .url(noteUrl) + .header("Authorization", Credentials.basic(username, password)) + .put(newContent.toRequestBody("text/plain".toMediaType())) + .build() + + val response = client.newCall(putRequest).execute() + if (!response.isSuccessful) { + throw Exception("Failed to add note: ${response.code} ${response.message}") + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/SkillSettingsNextcloudNotesSerializer.kt b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/SkillSettingsNextcloudNotesSerializer.kt new file mode 100644 index 000000000..4f1ea5986 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/SkillSettingsNextcloudNotesSerializer.kt @@ -0,0 +1,23 @@ +package org.stypox.dicio.skills.nextcloud_notes + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +object SkillSettingsNextcloudNotesSerializer : Serializer { + override val defaultValue: SkillSettingsNextcloudNotes = SkillSettingsNextcloudNotes.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): SkillSettingsNextcloudNotes { + try { + return SkillSettingsNextcloudNotes.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto", exception) + } + } + + override suspend fun writeTo(t: SkillSettingsNextcloudNotes, output: OutputStream) { + t.writeTo(output) + } +} diff --git a/app/src/main/proto/skill_settings_nextcloud_notes.proto b/app/src/main/proto/skill_settings_nextcloud_notes.proto new file mode 100644 index 000000000..67f194e06 --- /dev/null +++ b/app/src/main/proto/skill_settings_nextcloud_notes.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +option java_package = "org.stypox.dicio.skills.nextcloud_notes"; +option java_multiple_files = true; + +message SkillSettingsNextcloudNotes { + string server_address = 1; + string username = 2; + string password = 3; + string target_note = 4; + string target_shopping_list = 5; +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8230af59d..c83c23116 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,4 +246,24 @@ Failed to copy to clipboard Auto DuckDuckGo did not provide results, asking for a Captcha to be solved + Nextcloud Notes + Add a note to buy milk + Added note: %1$s + Added to shopping list: %1$s + Saved to: %1$s + Nextcloud server settings are not configured. Please configure them in settings. + Target note name is not configured. Please configure it in settings. + Target shopping list name is not configured. Please configure it in settings. + The note content cannot be empty. + Failed to connect to Nextcloud: %1$s + Nextcloud server address + e.g., https://cloud.example.com + Username + Your Nextcloud username + Password or App Password + For security, use an app password + Target note name + Note filename with extension (e.g., MyNotes.md or notes.txt) + Shopping list note name + Shopping list filename with extension (e.g., Shopping.md) diff --git a/app/src/main/sentences/en/nextcloud_notes.yml b/app/src/main/sentences/en/nextcloud_notes.yml new file mode 100644 index 000000000..665c1ee69 --- /dev/null +++ b/app/src/main/sentences/en/nextcloud_notes.yml @@ -0,0 +1,19 @@ +add_note: + - (could|can|would) you? please? (add|take|make|create|write) a note .content. + - (i d|would) like you? to? (add|take|make|write) a note .content. + - please? (add|take|make|create|write) a note .content. + - note .content. + - (please? take|make) a note (of|that|about)? .content. + - (please? remind|remember) me .content. + - (i|we)? need to remember .content. + - (write|jot) (down|that down)? .content. + +add_to_shopping_list: + - (could|can|would) you? please? (add|put) .item. (to|on|in) (my|our|the)? (shopping|grocery) list + - (i d|would) like you? to? (add|put) .item. (to|on) (my|our|the)? (shopping|grocery) list + - please? (add|put|write) .item. (to|on|in) (my|our|the)? (shopping|grocery) list + - (shopping|grocery) list add .item. + - (i|we) need to (get|buy|pick up|purchase) .item. + - (i|we) need .item. (from the store|on the list)? + - add .item. to my? (shopping|grocery|groceries|shop) + - put .item. on (my|our|the) (shopping|grocery) list diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index 7aa23f72f..34d72d17a 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -123,3 +123,15 @@ skills: type: string - id: target type: string + + - id: nextcloud_notes + specificity: high + sentences: + - id: add_note + captures: + - id: content + type: string + - id: add_to_shopping_list + captures: + - id: item + type: string From 945152162945eb20076e28f499cb3a5d82e555a4 Mon Sep 17 00:00:00 2001 From: tylxr <102394635+tylxr59@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:13:46 -0500 Subject: [PATCH 2/3] Update README and Fastlane --- README.md | 1 + fastlane/metadata/android/en-US/full_description.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index cb3b8fe06..213fe89b3 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Currently Dicio answers questions about: - **media**: play, pause, previous, next song - **translation**: translate from/to any language with **Lingva** - _How do I say Football in German?_ - **wake word control**: turn on/off the wakeword - _Stop listening_ +- **nextcloud notes**: add notes to a Nextcloud instance, including a separate grocery list - _Take a note to implement Nextcloud support in Dicio_ ## Speech to text diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index f572451fc..1beb336bc 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -14,6 +14,7 @@ Dicio answers questions about:
  • jokes: tells you a joke - Tell me a joke
  • media: play, pause, previous, next song - Next Song
  • translation: translate from/to any language with Lingva - How do I say Football in German?
  • +
  • nextcloud notes: add notes to a Nextcloud instance, including a separate grocery list - Take a note to implement Nextcloud support in Dicio
  • Dicio can receive input through a text box or through Vosk speech to text, and can talk using toasts or the Android speech synthesis engine. Interactive graphical output is provided by skills when they answer a question. From 103ac8f7517773a01c941ceba538b00bc0dd4cc9 Mon Sep 17 00:00:00 2001 From: tylxr <102394635+tylxr59@users.noreply.github.com> Date: Thu, 18 Dec 2025 20:54:38 -0500 Subject: [PATCH 3/3] Update Nextcloud Notes skill --- .../nextcloud_notes/NextcloudNotesOutput.kt | 78 +++++- .../nextcloud_notes/NextcloudNotesSkill.kt | 244 +++++++++++++++--- app/src/main/res/values/strings.xml | 8 +- app/src/main/sentences/en/nextcloud_notes.yml | 22 +- app/src/main/sentences/skill_definitions.yml | 5 + 5 files changed, 305 insertions(+), 52 deletions(-) diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesOutput.kt index d51198c66..52a25142c 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesOutput.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesOutput.kt @@ -1,11 +1,15 @@ package org.stypox.dicio.skills.nextcloud_notes import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import org.dicio.skill.context.SkillContext import org.dicio.skill.skill.SkillOutput import org.stypox.dicio.R @@ -45,6 +49,78 @@ sealed interface NextcloudNotesOutput : SkillOutput { } } + data class QueryShoppingListSuccess( + val noteName: String, + val items: List + ) : NextcloudNotesOutput, HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String { + return if (items.isEmpty()) { + ctx.getString(R.string.skill_nextcloud_notes_shopping_list_empty) + } else { + val itemsText = items.joinToString(", ") + ctx.getString(R.string.skill_nextcloud_notes_shopping_list_items, items.size, itemsText) + } + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column { + Text( + text = if (items.isEmpty()) { + stringResource(R.string.skill_nextcloud_notes_shopping_list_empty) + } else { + stringResource(R.string.skill_nextcloud_notes_shopping_list_num_of_items, items.size) + }, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.testTag("nextcloud_notes_shopping_list_title") + ) + if (items.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + items.forEachIndexed { index, item -> + Text( + text = "- $item", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.testTag("nextcloud_notes_shopping_item_$index") + ) + } + } + Text( + text = stringResource(R.string.skill_nextcloud_notes_saved_to, noteName), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + data class ItemFound( + val item: String, + val found: Boolean + ) : NextcloudNotesOutput, HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String { + return if (found) { + ctx.getString(R.string.skill_nextcloud_notes_item_found, item) + } else { + ctx.getString(R.string.skill_nextcloud_notes_item_not_found, item) + } + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column { + Text( + text = if (found) { + stringResource(R.string.skill_nextcloud_notes_item_found, item) + } else { + stringResource(R.string.skill_nextcloud_notes_item_not_found, item) + }, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.testTag("nextcloud_notes_item_check") + ) + } + } + } + data class Failed( val reason: FailureReason, val errorMessage: String? = null @@ -56,7 +132,7 @@ sealed interface NextcloudNotesOutput : SkillOutput { FailureReason.CONTENT_EMPTY -> ctx.getString(R.string.skill_nextcloud_notes_content_empty) FailureReason.CONNECTION_ERROR -> ctx.getString( R.string.skill_nextcloud_notes_connection_error, - errorMessage ?: "Unknown error" + errorMessage ?: ctx.getString(R.string.skill_nextcloud_notes_unknown_error) ) } } diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesSkill.kt index 742b144c5..fb702c2b9 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesSkill.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/nextcloud_notes/NextcloudNotesSkill.kt @@ -13,6 +13,7 @@ import org.dicio.skill.standard.StandardRecognizerData import org.dicio.skill.standard.StandardRecognizerSkill import org.stypox.dicio.sentences.Sentences.NextcloudNotes import org.stypox.dicio.skills.nextcloud_notes.NextcloudNotesInfo.nextcloudNotesDataStore +import org.stypox.dicio.util.StringUtils import java.net.URLEncoder import java.text.SimpleDateFormat import java.util.Date @@ -26,59 +27,140 @@ class NextcloudNotesSkill( override suspend fun generateOutput(ctx: SkillContext, inputData: NextcloudNotes): SkillOutput { val prefs = ctx.android.nextcloudNotesDataStore.data.first() - // Validate settings if (prefs.serverAddress.isEmpty() || prefs.username.isEmpty() || prefs.password.isEmpty()) { return NextcloudNotesOutput.Failed( reason = NextcloudNotesOutput.FailureReason.SETTINGS_MISSING ) } - val (content, targetNote) = when (inputData) { + return when (inputData) { is NextcloudNotes.AddNote -> { if (prefs.targetNote.isEmpty()) { return NextcloudNotesOutput.Failed( reason = NextcloudNotesOutput.FailureReason.TARGET_NOTE_MISSING ) } - Pair(inputData.content ?: "", prefs.targetNote) + val content = inputData.content ?: "" + if (content.isEmpty()) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.CONTENT_EMPTY + ) + } + + try { + addNoteViaWebDAV( + serverAddress = prefs.serverAddress, + username = prefs.username, + password = prefs.password, + noteName = prefs.targetNote, + content = content, + isShoppingList = false + ) + return NextcloudNotesOutput.Success( + noteName = prefs.targetNote, + content = content, + isShoppingList = false + ) + } catch (e: Exception) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.CONNECTION_ERROR, + errorMessage = e.message + ) + } } + is NextcloudNotes.AddToShoppingList -> { if (prefs.targetShoppingList.isEmpty()) { return NextcloudNotesOutput.Failed( reason = NextcloudNotesOutput.FailureReason.TARGET_SHOPPING_LIST_MISSING ) } - Pair(inputData.item ?: "", prefs.targetShoppingList) + val content = inputData.item ?: "" + if (content.isEmpty()) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.CONTENT_EMPTY + ) + } + + try { + addNoteViaWebDAV( + serverAddress = prefs.serverAddress, + username = prefs.username, + password = prefs.password, + noteName = prefs.targetShoppingList, + content = content, + isShoppingList = true + ) + return NextcloudNotesOutput.Success( + noteName = prefs.targetShoppingList, + content = content, + isShoppingList = true + ) + } catch (e: Exception) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.CONNECTION_ERROR, + errorMessage = e.message + ) + } + } + + is NextcloudNotes.QueryShoppingList -> { + if (prefs.targetShoppingList.isEmpty()) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.TARGET_SHOPPING_LIST_MISSING + ) + } + + try { + return queryShoppingList( + serverAddress = prefs.serverAddress, + username = prefs.username, + password = prefs.password, + noteName = prefs.targetShoppingList + ) + } catch (e: Exception) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.CONNECTION_ERROR, + errorMessage = e.message + ) + } + } + + is NextcloudNotes.CheckShoppingList -> { + if (prefs.targetShoppingList.isEmpty()) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.TARGET_SHOPPING_LIST_MISSING + ) + } + val item = inputData.item ?: "" + if (item.isEmpty()) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.CONTENT_EMPTY + ) + } + + try { + return checkShoppingListItem( + serverAddress = prefs.serverAddress, + username = prefs.username, + password = prefs.password, + noteName = prefs.targetShoppingList, + itemToCheck = item + ) + } catch (e: Exception) { + return NextcloudNotesOutput.Failed( + reason = NextcloudNotesOutput.FailureReason.CONNECTION_ERROR, + errorMessage = e.message + ) + } } } + } - if (content.isEmpty()) { - return NextcloudNotesOutput.Failed( - reason = NextcloudNotesOutput.FailureReason.CONTENT_EMPTY - ) - } - - // Add note via WebDAV - return try { - addNoteViaWebDAV( - serverAddress = prefs.serverAddress, - username = prefs.username, - password = prefs.password, - noteName = targetNote, - content = content, - isShoppingList = inputData is NextcloudNotes.AddToShoppingList - ) - NextcloudNotesOutput.Success( - noteName = targetNote, - content = content, - isShoppingList = inputData is NextcloudNotes.AddToShoppingList - ) - } catch (e: Exception) { - NextcloudNotesOutput.Failed( - reason = NextcloudNotesOutput.FailureReason.CONNECTION_ERROR, - errorMessage = e.message - ) - } + private fun buildNoteUrl(serverAddress: String, username: String, noteName: String): String { + val normalizedServer = serverAddress.trimEnd('/') + val encodedNoteName = URLEncoder.encode(noteName, "UTF-8") + return "$normalizedServer/remote.php/dav/files/$username/Notes/$encodedNoteName" } private suspend fun addNoteViaWebDAV( @@ -90,16 +172,7 @@ class NextcloudNotesSkill( isShoppingList: Boolean ) { val client = OkHttpClient() - - // Normalize server address (remove trailing slash) - val normalizedServer = serverAddress.trimEnd('/') - - // Encode note name for URL (Nextcloud Notes stores in /Notes/ folder) - // User provides the full filename with extension (e.g., "MyNote.md" or "Shopping.txt") - val encodedNoteName = URLEncoder.encode(noteName, "UTF-8") - - // Nextcloud stores notes in WebDAV files under the Notes folder - val noteUrl = "$normalizedServer/remote.php/dav/files/$username/Notes/$encodedNoteName" + val noteUrl = buildNoteUrl(serverAddress, username, noteName) // Get existing content first val getRequest = Request.Builder() @@ -115,7 +188,7 @@ class NextcloudNotesSkill( } else { "" } - } catch (e: Exception) { + } catch (_: Exception) { "" } @@ -137,7 +210,6 @@ class NextcloudNotesSkill( } } - // Upload the updated content val putRequest = Request.Builder() .url(noteUrl) .header("Authorization", Credentials.basic(username, password)) @@ -149,4 +221,90 @@ class NextcloudNotesSkill( throw Exception("Failed to add note: ${response.code} ${response.message}") } } + + private suspend fun queryShoppingList( + serverAddress: String, + username: String, + password: String, + noteName: String + ): NextcloudNotesOutput { + val content = fetchNoteContent(serverAddress, username, password, noteName) + val items = parseShoppingListItems(content) + + return NextcloudNotesOutput.QueryShoppingListSuccess( + noteName = noteName, + items = items + ) + } + + private suspend fun checkShoppingListItem( + serverAddress: String, + username: String, + password: String, + noteName: String, + itemToCheck: String + ): NextcloudNotesOutput { + val content = fetchNoteContent(serverAddress, username, password, noteName) + val items = parseShoppingListItems(content) + + val matchedItem = items.firstOrNull { item -> + StringUtils.customStringDistance(item, itemToCheck.trim()) <= 3 + } + + return NextcloudNotesOutput.ItemFound( + item = matchedItem ?: itemToCheck.trim(), + found = matchedItem != null + ) + } + + private suspend fun fetchNoteContent( + serverAddress: String, + username: String, + password: String, + noteName: String + ): String { + val client = OkHttpClient() + val noteUrl = buildNoteUrl(serverAddress, username, noteName) + + val getRequest = Request.Builder() + .url(noteUrl) + .header("Authorization", Credentials.basic(username, password)) + .get() + .build() + + val response = client.newCall(getRequest).execute() + if (!response.isSuccessful) { + throw Exception("Failed to fetch note: ${response.code} ${response.message}") + } + + return response.body?.string() ?: "" + } + + private fun parseShoppingListItems(content: String): List { + if (content.isBlank()) return emptyList() + + val checkboxRegex = Regex("^\\s*-\\s*\\[.?\\]\\s*(.+)$") + + return content.lines() + .mapNotNull { line -> + checkboxRegex.find(line)?.let { match -> + stripMarkdownForSpeech(match.groupValues[1].trim()) + .takeIf { it.isNotBlank() } + } + } + } + + private fun stripMarkdownForSpeech(text: String): String { + return text + // Remove markdown headers + .replace(Regex("^#{1,6}\\s+"), "") + // Remove bold/italic markers + .replace(Regex("[*_]{1,2}"), "") + // Remove links but keep text: [text](url) -> text + .replace(Regex("\\[([^]]+)\\]\\([^)]+\\)"), "$1") + // Remove inline code markers + .replace(Regex("`([^`]+)`"), "$1") + // Clean up extra whitespace + .trim() + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c83c23116..e412a5724 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -256,11 +256,17 @@ Target shopping list name is not configured. Please configure it in settings. The note content cannot be empty. Failed to connect to Nextcloud: %1$s + Unknown error + Your shopping list has %1$d items + You have %1$d items to get: %2$s + Your shopping list is empty + Yes, %1$s is on your shopping list + No, %1$s is not on your shopping list Nextcloud server address e.g., https://cloud.example.com Username Your Nextcloud username - Password or App Password + Nextcloud App Password For security, use an app password Target note name Note filename with extension (e.g., MyNotes.md or notes.txt) diff --git a/app/src/main/sentences/en/nextcloud_notes.yml b/app/src/main/sentences/en/nextcloud_notes.yml index 665c1ee69..1dfefec4b 100644 --- a/app/src/main/sentences/en/nextcloud_notes.yml +++ b/app/src/main/sentences/en/nextcloud_notes.yml @@ -2,18 +2,26 @@ add_note: - (could|can|would) you? please? (add|take|make|create|write) a note .content. - (i d|would) like you? to? (add|take|make|write) a note .content. - please? (add|take|make|create|write) a note .content. - - note .content. - (please? take|make) a note (of|that|about)? .content. - - (please? remind|remember) me .content. - - (i|we)? need to remember .content. - - (write|jot) (down|that down)? .content. add_to_shopping_list: - (could|can|would) you? please? (add|put) .item. (to|on|in) (my|our|the)? (shopping|grocery) list - (i d|would) like you? to? (add|put) .item. (to|on) (my|our|the)? (shopping|grocery) list - - please? (add|put|write) .item. (to|on|in) (my|our|the)? (shopping|grocery) list - (shopping|grocery) list add .item. - (i|we) need to (get|buy|pick up|purchase) .item. - (i|we) need .item. (from the store|on the list)? - - add .item. to my? (shopping|grocery|groceries|shop) - - put .item. on (my|our|the) (shopping|grocery) list + - please? (add|put|write) .item. (on|to|in) (my|our|the)? (shopping|grocery|groceries)? list? + +query_shopping_list: + - (what|what s|what item? is|what items? (do|are)) (written|noted|I have|we have)? on (my|our|the)? (shopping|grocery) list + - (what do|what did) (i|we) need (to (get|buy|pick up|purchase))? + - read (my|our|the)? (shopping|grocery) list + - (show|tell) me (my|our|the)? (shopping|grocery) list + - (shopping|grocery) list + +check_shopping_list: + - (do|did) (i|we) (add|put|need) .item. (to|on) (my|our|the)? (shopping|grocery) list + - (do|did) (i|we) (need|have) .item. (on the list)? + - is .item. on (my|our|the)? (shopping|grocery) list + - is .item. (already)? (there|on there) + - check (for|if) .item. (is)? (on|in) (my|our|the)? (shopping|grocery)? list diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index 34d72d17a..9c2ac3b64 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -135,3 +135,8 @@ skills: captures: - id: item type: string + - id: query_shopping_list + - id: check_shopping_list + captures: + - id: item + type: string