Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ import java.io.File
data class CompleteExampleSettings(
@PluginSetting(
description = "Public configuration value example",
defaultValue = "default_api_key"
defaultValue = "default_api_key",
minLength = 8,
semanticTypes = ["text/plain"]
) val apiKey: String? = "default_api_key",

@PluginSetting(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ class PluginLifecycleManager(
}

val decryptedStore = store.copy(settings = decryptedSettings)
.withResolvedAutogeneratedSettings(manifest?.settings.orEmpty())
_pluginSettingsState.update { it + (pkg to decryptedStore) }
return decryptedStore
}
Expand All @@ -296,7 +297,8 @@ class PluginLifecycleManager(
val settingsFile = "${plugin.installPath}/settings.json"

val manifest = getManifest(pkg)
val encryptedSettings = store.settings.mapValues { (key, value) ->
val resolvedStore = store.withResolvedAutogeneratedSettings(manifest?.settings.orEmpty())
val encryptedSettings = resolvedStore.settings.mapValues { (key, value) ->
val isSecret = manifest?.settings?.get(key)?.secret == true
if (isSecret && value is kotlinx.serialization.json.JsonPrimitive && value.isString) {
val encrypted = org.wip.plugintoolkit.core.utils.SecureStorage.encrypt(value.content)
Expand All @@ -305,12 +307,12 @@ class PluginLifecycleManager(
value
}
}
val storeToSave = store.copy(settings = encryptedSettings)
val storeToSave = resolvedStore.copy(settings = encryptedSettings)

try {
fileSystem.writeFile(settingsFile, json.encodeToString(storeToSave))
// Update cache with the decrypted store
_pluginSettingsState.update { it + (pkg to store) }
_pluginSettingsState.update { it + (pkg to resolvedStore) }
} catch (t: Throwable) {
Logger.e(t) { "Failed to save settings for $pkg" }
}
Expand All @@ -330,8 +332,9 @@ class PluginLifecycleManager(
val installPath = plugin?.installPath ?: ""
val jarFullPath = plugin?.let { "${it.installPath}/${it.jarFileName}" }

val storedSettings = overriddenSettings ?: loadPluginSettings(pkg)
val actualManifest = manifest ?: getManifest(pkg)
val storedSettings = (overriddenSettings ?: loadPluginSettings(pkg))
.withResolvedAutogeneratedSettings(actualManifest?.settings.orEmpty())
val mergedSettings = mutableMapOf<String, JsonElement>()

// 1. Manifest defaults
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package org.wip.plugintoolkit.features.plugin.logic

import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import org.wip.plugintoolkit.api.DataType
import org.wip.plugintoolkit.api.PrimitiveType
import org.wip.plugintoolkit.api.SettingMetadata
import org.wip.plugintoolkit.features.flows.logic.PathPatternResolver
import org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore
import org.wip.plugintoolkit.features.plugin.utils.SettingsUtils

internal fun resolveAutogeneratedSettings(
metadata: Map<String, SettingMetadata>,
settings: Map<String, JsonElement>,
additionalValues: Map<String, JsonElement> = emptyMap()
): Map<String, JsonElement> {
val resolvedSettings = settings.toMutableMap()
val defaults = metadata.mapNotNull { (key, value) -> value.defaultValue?.let { key to it } }.toMap()

repeat(metadata.size.coerceAtLeast(1)) {
var changed = false
metadata.forEach { (key, settingMetadata) ->
val pattern = settingMetadata.autogeneratedPattern?.takeIf { it.isNotBlank() } ?: return@forEach
val availableValues = defaults + additionalValues + resolvedSettings
val stringValues = availableValues.mapValues { (valueKey, value) ->
val valueType = metadata[valueKey]?.type
if (valueType != null) SettingsUtils.jsonToString(value, valueType) else value.toString().trim('"')
}
val generated = runCatching { PathPatternResolver.tryResolve(pattern, stringValues) }.getOrNull()
?: return@forEach
val generatedValue = generatedSettingValue(generated, settingMetadata.type) ?: return@forEach
if (resolvedSettings[key] != generatedValue) {
resolvedSettings[key] = generatedValue
changed = true
}
}
if (!changed) return resolvedSettings
}

return resolvedSettings
}

internal fun PluginSettingsStore.withResolvedAutogeneratedSettings(
metadata: Map<String, SettingMetadata>
): PluginSettingsStore = copy(
settings = resolveAutogeneratedSettings(metadata, settings, globalParams)
)

private fun generatedSettingValue(value: String, type: DataType): JsonElement? = when (type) {
is DataType.Primitive -> when (type.primitiveType) {
PrimitiveType.STRING, PrimitiveType.ANY, PrimitiveType.UNKNOWN -> JsonPrimitive(value)
PrimitiveType.BOOLEAN -> value.toBooleanStrictOrNull()?.let(::JsonPrimitive)
PrimitiveType.INT -> value.toIntOrNull()?.let(::JsonPrimitive)
PrimitiveType.LONG -> value.toLongOrNull()?.let(::JsonPrimitive)
PrimitiveType.SHORT -> value.toShortOrNull()?.let { JsonPrimitive(it.toInt()) }
PrimitiveType.BYTE -> value.toByteOrNull()?.let { JsonPrimitive(it.toInt()) }
PrimitiveType.DOUBLE -> value.toDoubleOrNull()?.let(::JsonPrimitive)
PrimitiveType.FLOAT -> value.toFloatOrNull()?.let { JsonPrimitive(it.toDouble()) }
PrimitiveType.UNIT -> null
}
else -> value.takeIf { it.isNotBlank() }?.let { runCatching { SettingsUtils.stringToJson(it, type) }.getOrNull() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -483,8 +483,11 @@ fun PluginSettingsContent(
description = meta.description,
type = meta.type,
defaultValue = meta.defaultValue,
constraints = meta.constraints,
required = meta.required,
secret = meta.secret
secret = meta.secret,
semanticTypes = meta.semanticTypes,
autogeneratedPattern = meta.autogeneratedPattern
),
value = SettingsUtils.jsonToString(value, meta.type),
onValueChange = {
Expand All @@ -493,7 +496,8 @@ fun PluginSettingsContent(
SettingsUtils.stringToJson(it, meta.type)
)
},
enabled = !isBusy,
enabled = !isBusy && meta.autogeneratedPattern == null,
isAutoGenerated = meta.autogeneratedPattern != null,
providedSettings = providedSettings,
providedLocks = locks
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,29 @@ import kotlinx.serialization.json.JsonElement
import org.wip.plugintoolkit.features.job.logic.JobManager
import org.wip.plugintoolkit.features.job.model.JobStatus
import org.wip.plugintoolkit.features.plugin.logic.PluginManager
import org.wip.plugintoolkit.features.plugin.logic.withResolvedAutogeneratedSettings
import org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore

class PluginSettingsViewModel(
val pkg: String,
private val pluginManager: PluginManager,
private val jobManager: JobManager,
) : ViewModel() {
private val _store = MutableStateFlow(pluginManager.loadPluginSettings(pkg))
val manifest = pluginManager.getManifest(pkg)
private val initialStore = pluginManager.loadPluginSettings(pkg)
private val _store = MutableStateFlow(initialStore.withAutogeneratedSettings())
val store = _store.asStateFlow()

private val _isBusy = MutableStateFlow(false)
val isBusy = _isBusy.asStateFlow()

val manifest = pluginManager.getManifest(pkg)

val locks = MutableStateFlow<Map<String, Boolean>>(emptyMap())

private fun PluginSettingsStore.withAutogeneratedSettings(): PluginSettingsStore {
val settingMetadata = manifest?.settings ?: return this
return withResolvedAutogeneratedSettings(settingMetadata)
}

init {
viewModelScope.launch {
pluginManager.refreshLocks(pkg)
Expand All @@ -45,7 +52,7 @@ class PluginSettingsViewModel(

fun updateSetting(key: String, value: JsonElement) {
_store.update { current ->
val updated = current.copy(settings = current.settings + (key to value))
val updated = current.copy(settings = current.settings + (key to value)).withAutogeneratedSettings()
viewModelScope.launch {
val newLocks = pluginManager.refreshLocks(pkg, updated)
locks.value = newLocks
Expand All @@ -56,7 +63,7 @@ class PluginSettingsViewModel(

fun updateGlobalParam(key: String, value: JsonElement) {
_store.update { current ->
val updated = current.copy(globalParams = current.globalParams + (key to value))
val updated = current.copy(globalParams = current.globalParams + (key to value)).withAutogeneratedSettings()
viewModelScope.launch {
val newLocks = pluginManager.refreshLocks(pkg, updated)
locks.value = newLocks
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package org.wip.plugintoolkit.features.plugin.viewmodel

import kotlinx.serialization.json.JsonPrimitive
import org.wip.plugintoolkit.api.DataType
import org.wip.plugintoolkit.api.PrimitiveType
import org.wip.plugintoolkit.api.SettingMetadata
import org.wip.plugintoolkit.features.plugin.logic.resolveAutogeneratedSettings
import kotlin.test.Test
import kotlin.test.assertEquals

class PluginSettingsAutogenerationTest {
private val stringType = DataType.Primitive(PrimitiveType.STRING)
private val metadata = mapOf(
"input" to SettingMetadata(description = "Input", type = stringType),
"output" to SettingMetadata(
description = "Output",
type = stringType,
autogeneratedPattern = "{input.dir}/{input.nameWithoutExtension}.result"
)
)

@Test
fun `derived setting follows its dependency`() {
val result = resolveAutogeneratedSettings(
metadata = metadata,
settings = mapOf("input" to JsonPrimitive("work/photo.png"))
)

assertEquals(JsonPrimitive("work/photo.result"), result["output"])
}

@Test
fun `derived setting preserves the last explicit value when dependency is missing`() {
val result = resolveAutogeneratedSettings(
metadata = metadata,
settings = mapOf("output" to JsonPrimitive("stale.result"))
)

assertEquals(JsonPrimitive("stale.result"), result["output"])
}

@Test
fun `invalid generated value does not replace a valid persisted value`() {
val numberType = DataType.Primitive(PrimitiveType.INT)
val result = resolveAutogeneratedSettings(
metadata = metadata + (
"count" to SettingMetadata(
description = "Count",
type = numberType,
autogeneratedPattern = "{input.nameWithoutExtension}"
)
),
settings = mapOf(
"input" to JsonPrimitive("photo.png"),
"count" to JsonPrimitive(42)
)
)

assertEquals(JsonPrimitive(42), result["count"])
}
}
5 changes: 4 additions & 1 deletion docs/PluginDevelopment.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ The `@PluginSetting` annotation supports identical validation constraints to tho
data class MyAdvancedSettings(
@PluginSetting(
description = "Service Endpoint",
regex = "^https?://.*"
regex = "^https?://.*",
semanticTypes = ["text/uri"]
) val endpoint: String,

@PluginSetting(
Expand All @@ -48,6 +49,8 @@ data class MyAdvancedSettings(
)
```

Settings also accept `semanticTypes` and `pathTemplate`, matching capability parameters. Semantic types select specialized controls such as color or file inputs; a path template derives a value from other configured fields.

### 2. Capabilities

A plugin provides one or more **Capabilities**. These are functions annotated with `@Capability`. Each capability becomes a task that the host application can execute.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,39 @@ data class SettingMetadata(
* This allows UI to show which capabilities are locked behind this setting
* without making the setting globally required for the plugin to load.
*/
val requiredByCapabilities: List<String> = emptyList()
)
val requiredByCapabilities: List<String> = emptyList(),
// Keep new fields after the original constructor fields so component7 retains
// its pre-existing requiredByCapabilities meaning for old compiled callers.
val semanticTypes: List<SemanticType> = emptyList(),
val autogeneratedPattern: String? = null
) {
/**
* Retains the JVM constructor used by plugins compiled before semantic hints and
* autogenerated patterns were added. The host classloader deliberately shares
* plugin-api classes, so removing this descriptor would break installed plugins
* before their compatibility metadata can be inspected.
*/
@Deprecated("Binary compatibility constructor", level = DeprecationLevel.HIDDEN)
constructor(
defaultValue: JsonElement? = null,
description: String,
type: DataType,
required: Boolean = false,
secret: Boolean = false,
constraints: ParameterConstraints? = null,
requiredByCapabilities: List<String> = emptyList()
) : this(
defaultValue = defaultValue,
description = description,
type = type,
required = required,
secret = secret,
constraints = constraints,
requiredByCapabilities = requiredByCapabilities,
semanticTypes = emptyList(),
autogeneratedPattern = null
)
}

/**
* The complete manifest of a plugin, describing its capabilities and requirements.
Expand Down Expand Up @@ -632,4 +663,3 @@ data class PluginAction(
val functionName: String,
val parameters: Map<String, ParameterMetadata>? = null
)

Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ annotation class CapabilityOutput(
* @property defaultValue The default value for the setting (as a string).
* @property required Whether the setting is mandatory for the plugin to function.
* @property secret Whether the setting contains sensitive information (e.g., API keys).
* @property semanticTypes Semantic hints used to select a specialized editor (for example `color/rgb`).
* @property pathTemplate Optional template used to derive this setting from other values.
*/
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
Expand All @@ -184,7 +186,9 @@ annotation class PluginSetting(
val regex: String = "",
val multiSelect: Boolean = false,
val minChoices: Int = -1,
val maxChoices: Int = -1
val maxChoices: Int = -1,
val semanticTypes: Array<String> = [],
val pathTemplate: String = ""
)

/**
Expand Down Expand Up @@ -271,4 +275,4 @@ annotation class ComplexObject(
val id: String = "",
val description: String = "",
val version: Int = 1
)
)
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
package org.wip.plugintoolkit.api

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals

class ManifestModelsTest {
@Test
fun `setting metadata preserves capability-style input hints`() {
val metadata = SettingMetadata(
defaultValue = JsonPrimitive("#112233"),
description = "Brand color",
type = DataType.Primitive(PrimitiveType.STRING),
constraints = ParameterConstraints(regex = "#[0-9A-Fa-f]{6}"),
semanticTypes = parseSemanticTypes("color/rgb"),
autogeneratedPattern = "{theme}/brand.hex"
)
val json = Json { encodeDefaults = true }

val decoded = json.decodeFromString<SettingMetadata>(json.encodeToString(metadata))

assertEquals(metadata.constraints, decoded.constraints)
assertEquals(metadata.semanticTypes, decoded.semanticTypes)
assertEquals(metadata.autogeneratedPattern, decoded.autogeneratedPattern)
}

@Test
fun testCapabilityDeserialization() {
val jsonString = """{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,12 @@ object ManifestJsonGenerator {
val multiSelect = ann.arguments.find { it.name?.asString() == "multiSelect" }?.value as? Boolean ?: false
val minChoices = ann.arguments.find { it.name?.asString() == "minChoices" }?.value as? Int ?: -1
val maxChoices = ann.arguments.find { it.name?.asString() == "maxChoices" }?.value as? Int ?: -1
val semanticTypes =
(ann.arguments.find { it.name?.asString() == "semanticTypes" }?.value as? List<*>)
?.filterIsInstance<String>()
?.flatMap { parseSemanticTypes(it) }
?: emptyList()
val pathTemplate = ann.arguments.find { it.name?.asString() == "pathTemplate" }?.value as? String ?: ""

val hasConstraints =
!minValue.isNaN() || !maxValue.isNaN() || minLength != -1 || maxLength != -1 || regex.isNotEmpty() || multiSelect || minChoices != -1 || maxChoices != -1
Expand All @@ -303,6 +309,8 @@ object ManifestJsonGenerator {
required = required,
secret = secret,
constraints = constraints,
semanticTypes = semanticTypes,
autogeneratedPattern = pathTemplate.ifBlank { null },
requiredByCapabilities = requiredBy
)
}
Expand Down
Loading