Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,6 +56,7 @@ class SkillHandler @Inject constructor(
JokeInfo,
ListeningInfo(dataStore),
TranslationInfo,
NextcloudNotesInfo,
)

private val fallbackSkillInfoList = listOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that having a setting for note names is the best way of doing it. I think it might be better to pass the note name as a input value and have a setting for either choosing the file extension or a default note name. That way, if someone wants to make a new note or a note about a different topic, they won't have to stick it in one main note file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could do something like dicio-{timestamp}.md as a default but adding a .note_topic. to the sentences for specific titles

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't fully resolve this in my latest commit. I tried messing around with various implementations of this and I wasn't happy with what I ended up with. I think trying to make sentences with .topic. in it makes it hard to cover a lot of natural speech.

There may be a better way to do this and I'll revisit it some time soon

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()
}
}
},
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<NextcloudNotes>
) : StandardRecognizerSkill<NextcloudNotes>(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}")
}
}
}
Loading