From fb55ba124570b2491bc3e9139914b6bdd5a9ff10 Mon Sep 17 00:00:00 2001
From: Felipe Martinez <pipernene@gmail.com>
Date: Sat, 23 Mar 2024 16:06:52 +0100
Subject: [PATCH] Add plugin parameters

---
 api/api.d.ts                                  |   2 +
 .../13.json                                   |  12 +-
 .../14.json                                   | 186 ++++++++++++++++++
 .../15.json                                   | 186 ++++++++++++++++++
 .../16.json                                   | 174 ++++++++++++++++
 .../pipe01/pinepartner/data/AppDatabase.kt    |   3 +-
 .../net/pipe01/pinepartner/data/Converters.kt |  14 ++
 .../net/pipe01/pinepartner/data/Plugin.kt     |   7 +-
 .../net/pipe01/pinepartner/data/PluginDao.kt  |   9 +
 .../pinepartner/data/PluginParameter.kt       |  10 +
 .../pages/plugins/ImportPluginPage.kt         |   1 +
 .../pinepartner/pages/plugins/PluginPage.kt   | 107 +++++++++-
 .../pinepartner/pages/plugins/PluginsPage.kt  |   1 +
 .../pipe01/pinepartner/scripting/Parameter.kt |  75 +++++++
 .../pipe01/pinepartner/scripting/Runner.kt    |  18 +-
 .../pinepartner/scripting/api/Parameters.kt   |  80 ++++++++
 16 files changed, 863 insertions(+), 22 deletions(-)
 create mode 100644 app/schemas/net.pipe01.pinepartner.data.AppDatabase/14.json
 create mode 100644 app/schemas/net.pipe01.pinepartner.data.AppDatabase/15.json
 create mode 100644 app/schemas/net.pipe01.pinepartner.data.AppDatabase/16.json
 create mode 100644 app/src/main/java/net/pipe01/pinepartner/data/PluginParameter.kt
 create mode 100644 app/src/main/java/net/pipe01/pinepartner/scripting/Parameter.kt
 create mode 100644 app/src/main/java/net/pipe01/pinepartner/scripting/api/Parameters.kt

diff --git a/api/api.d.ts b/api/api.d.ts
index 7e584a6..d417c8e 100644
--- a/api/api.d.ts
+++ b/api/api.d.ts
@@ -111,3 +111,5 @@ declare function require(module: "http"): HTTPService;
 declare function require(module: "volume"): VolumeService;
 declare function require(module: "media"): MediaService;
 declare function require(module: "location"): LocationService;
+
+declare const params: Record<string, string>;
diff --git a/app/schemas/net.pipe01.pinepartner.data.AppDatabase/13.json b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/13.json
index 9e11f83..ea10392 100644
--- a/app/schemas/net.pipe01.pinepartner.data.AppDatabase/13.json
+++ b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/13.json
@@ -2,7 +2,7 @@
   "formatVersion": 1,
   "database": {
     "version": 13,
-    "identityHash": "7373061d995feb6177a83d4ddeb71134",
+    "identityHash": "18bb291464b45777c7b265b8330ec047",
     "entities": [
       {
         "tableName": "Watch",
@@ -59,7 +59,7 @@
       },
       {
         "tableName": "Plugin",
-        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `author` TEXT, `sourceCode` TEXT NOT NULL, `checksum` TEXT NOT NULL, `permissions` TEXT NOT NULL, `downloadUrl` TEXT, `enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `author` TEXT, `sourceCode` TEXT NOT NULL, `checksum` TEXT NOT NULL, `permissions` TEXT NOT NULL, `parameters` TEXT NOT NULL, `downloadUrl` TEXT, `enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))",
         "fields": [
           {
             "fieldPath": "id",
@@ -103,6 +103,12 @@
             "affinity": "TEXT",
             "notNull": true
           },
+          {
+            "fieldPath": "parameters",
+            "columnName": "parameters",
+            "affinity": "TEXT",
+            "notNull": true
+          },
           {
             "fieldPath": "downloadUrl",
             "columnName": "downloadUrl",
@@ -129,7 +135,7 @@
     "views": [],
     "setupQueries": [
       "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
-      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7373061d995feb6177a83d4ddeb71134')"
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '18bb291464b45777c7b265b8330ec047')"
     ]
   }
 }
\ No newline at end of file
diff --git a/app/schemas/net.pipe01.pinepartner.data.AppDatabase/14.json b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/14.json
new file mode 100644
index 0000000..76f3be5
--- /dev/null
+++ b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/14.json
@@ -0,0 +1,186 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 14,
+    "identityHash": "50f62b0e1b18edde27a001c6c8e5f0a5",
+    "entities": [
+      {
+        "tableName": "Watch",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT NOT NULL, `autoConnect` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`address`))",
+        "fields": [
+          {
+            "fieldPath": "address",
+            "columnName": "address",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoConnect",
+            "columnName": "autoConnect",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "true"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "address"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "AllowedNotifApp",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, PRIMARY KEY(`packageName`))",
+        "fields": [
+          {
+            "fieldPath": "packageName",
+            "columnName": "packageName",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "packageName"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "Plugin",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `author` TEXT, `sourceCode` TEXT NOT NULL, `checksum` TEXT NOT NULL, `permissions` TEXT NOT NULL, `parameters` TEXT NOT NULL, `downloadUrl` TEXT, `enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sourceCode",
+            "columnName": "sourceCode",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "checksum",
+            "columnName": "checksum",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permissions",
+            "columnName": "permissions",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parameters",
+            "columnName": "parameters",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "downloadUrl",
+            "columnName": "downloadUrl",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "ParameterValue",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pluginId` TEXT NOT NULL, `paramName` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`pluginId`, `paramName`), FOREIGN KEY(`pluginId`) REFERENCES `Plugin`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "pluginId",
+            "columnName": "pluginId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paramName",
+            "columnName": "paramName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "pluginId",
+            "paramName"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": [
+          {
+            "table": "Plugin",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "pluginId"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      }
+    ],
+    "views": [],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '50f62b0e1b18edde27a001c6c8e5f0a5')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.pipe01.pinepartner.data.AppDatabase/15.json b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/15.json
new file mode 100644
index 0000000..97de491
--- /dev/null
+++ b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/15.json
@@ -0,0 +1,186 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 15,
+    "identityHash": "50f62b0e1b18edde27a001c6c8e5f0a5",
+    "entities": [
+      {
+        "tableName": "Watch",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT NOT NULL, `autoConnect` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`address`))",
+        "fields": [
+          {
+            "fieldPath": "address",
+            "columnName": "address",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoConnect",
+            "columnName": "autoConnect",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "true"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "address"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "AllowedNotifApp",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, PRIMARY KEY(`packageName`))",
+        "fields": [
+          {
+            "fieldPath": "packageName",
+            "columnName": "packageName",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "packageName"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "Plugin",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `author` TEXT, `sourceCode` TEXT NOT NULL, `checksum` TEXT NOT NULL, `permissions` TEXT NOT NULL, `parameters` TEXT NOT NULL, `downloadUrl` TEXT, `enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sourceCode",
+            "columnName": "sourceCode",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "checksum",
+            "columnName": "checksum",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permissions",
+            "columnName": "permissions",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parameters",
+            "columnName": "parameters",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "downloadUrl",
+            "columnName": "downloadUrl",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "ParameterValue",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pluginId` TEXT NOT NULL, `paramName` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`pluginId`, `paramName`), FOREIGN KEY(`pluginId`) REFERENCES `Plugin`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "pluginId",
+            "columnName": "pluginId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paramName",
+            "columnName": "paramName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "pluginId",
+            "paramName"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": [
+          {
+            "table": "Plugin",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "pluginId"
+            ],
+            "referencedColumns": [
+              "id"
+            ]
+          }
+        ]
+      }
+    ],
+    "views": [],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '50f62b0e1b18edde27a001c6c8e5f0a5')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/schemas/net.pipe01.pinepartner.data.AppDatabase/16.json b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/16.json
new file mode 100644
index 0000000..c5e0447
--- /dev/null
+++ b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/16.json
@@ -0,0 +1,174 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 16,
+    "identityHash": "d0ad1d3ddd199234aad119fa18cfdc47",
+    "entities": [
+      {
+        "tableName": "Watch",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT NOT NULL, `autoConnect` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`address`))",
+        "fields": [
+          {
+            "fieldPath": "address",
+            "columnName": "address",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoConnect",
+            "columnName": "autoConnect",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "true"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "address"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "AllowedNotifApp",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, PRIMARY KEY(`packageName`))",
+        "fields": [
+          {
+            "fieldPath": "packageName",
+            "columnName": "packageName",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "packageName"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "Plugin",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `author` TEXT, `sourceCode` TEXT NOT NULL, `checksum` TEXT NOT NULL, `permissions` TEXT NOT NULL, `parameters` TEXT NOT NULL, `downloadUrl` TEXT, `enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sourceCode",
+            "columnName": "sourceCode",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "checksum",
+            "columnName": "checksum",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "permissions",
+            "columnName": "permissions",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parameters",
+            "columnName": "parameters",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "downloadUrl",
+            "columnName": "downloadUrl",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "ParameterValue",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pluginId` TEXT NOT NULL, `paramName` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`pluginId`, `paramName`))",
+        "fields": [
+          {
+            "fieldPath": "pluginId",
+            "columnName": "pluginId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paramName",
+            "columnName": "paramName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "pluginId",
+            "paramName"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      }
+    ],
+    "views": [],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd0ad1d3ddd199234aad119fa18cfdc47')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/pipe01/pinepartner/data/AppDatabase.kt b/app/src/main/java/net/pipe01/pinepartner/data/AppDatabase.kt
index 3329eb0..87b60c2 100644
--- a/app/src/main/java/net/pipe01/pinepartner/data/AppDatabase.kt
+++ b/app/src/main/java/net/pipe01/pinepartner/data/AppDatabase.kt
@@ -11,8 +11,9 @@ import androidx.room.TypeConverters
         Watch::class,
         AllowedNotifApp::class,
         Plugin::class,
+        ParameterValue::class,
     ],
-    version = 13,
+    version = 16,
     exportSchema = true,
 )
 @TypeConverters(Converters::class)
diff --git a/app/src/main/java/net/pipe01/pinepartner/data/Converters.kt b/app/src/main/java/net/pipe01/pinepartner/data/Converters.kt
index f914df2..004c03f 100644
--- a/app/src/main/java/net/pipe01/pinepartner/data/Converters.kt
+++ b/app/src/main/java/net/pipe01/pinepartner/data/Converters.kt
@@ -1,6 +1,7 @@
 package net.pipe01.pinepartner.data
 
 import androidx.room.TypeConverter
+import net.pipe01.pinepartner.scripting.Parameter
 import net.pipe01.pinepartner.scripting.Permission
 
 class Converters {
@@ -16,4 +17,17 @@ class Converters {
         else
             value.split(",").map { Permission.valueOf(it) }.toSet()
     }
+
+    @TypeConverter
+    fun fromParameterList(value: List<Parameter>): String {
+        return value.joinToString("\n") { it.toString() }
+    }
+
+    @TypeConverter
+    fun toParameterList(value: String): List<Parameter> {
+        return if (value.isBlank())
+            emptyList()
+        else
+            value.split("\n").mapNotNull { Parameter.parse(it) }
+    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/net/pipe01/pinepartner/data/Plugin.kt b/app/src/main/java/net/pipe01/pinepartner/data/Plugin.kt
index a2bf4f8..bc57911 100644
--- a/app/src/main/java/net/pipe01/pinepartner/data/Plugin.kt
+++ b/app/src/main/java/net/pipe01/pinepartner/data/Plugin.kt
@@ -3,6 +3,7 @@ package net.pipe01.pinepartner.data
 import androidx.room.Entity
 import androidx.room.Ignore
 import androidx.room.PrimaryKey
+import net.pipe01.pinepartner.scripting.Parameter
 import net.pipe01.pinepartner.scripting.Permission
 import net.pipe01.pinepartner.utils.md5
 import java.util.Scanner
@@ -18,6 +19,7 @@ data class Plugin @JvmOverloads constructor(
     val sourceCode: String,
     val checksum: String,
     val permissions: Set<Permission>,
+    val parameters: List<Parameter>,
     val downloadUrl: String?,
     val enabled: Boolean,
     @Ignore val isBuiltIn: Boolean = false,
@@ -28,7 +30,8 @@ data class Plugin @JvmOverloads constructor(
             var id: String? = null
             var description: String? = null
             var author: String? = null
-            var permissions = mutableSetOf<Permission>()
+            val permissions = mutableSetOf<Permission>()
+            val parameters = mutableListOf<Parameter>()
 
             var foundFooter = false
 
@@ -73,6 +76,7 @@ data class Plugin @JvmOverloads constructor(
                         "@description" -> description = value
                         "@author" -> author = value
                         "@permission" -> permissions.add(Permission.valueOf(value))
+                        "@param" -> Parameter.parse(value)?.let { parameters.add(it) } ?: throw PluginParseException("Invalid parameter: $value")
                         else -> throw PluginParseException("Unknown plugin header key: $key")
                     }
                 }
@@ -100,6 +104,7 @@ data class Plugin @JvmOverloads constructor(
                 sourceCode = source,
                 checksum = source.md5(),
                 permissions = permissions,
+                parameters = parameters,
                 enabled = false,
                 downloadUrl = downloadUrl,
                 isBuiltIn = isBuiltIn,
diff --git a/app/src/main/java/net/pipe01/pinepartner/data/PluginDao.kt b/app/src/main/java/net/pipe01/pinepartner/data/PluginDao.kt
index 6e4bf38..258a8f7 100644
--- a/app/src/main/java/net/pipe01/pinepartner/data/PluginDao.kt
+++ b/app/src/main/java/net/pipe01/pinepartner/data/PluginDao.kt
@@ -27,4 +27,13 @@ interface PluginDao {
 
     @Query("UPDATE Plugin SET enabled = :enabled WHERE id = :id")
     suspend fun setEnabled(id: String, enabled: Boolean)
+
+    @Query("SELECT * FROM parametervalue WHERE pluginId = :id")
+    suspend fun getParameterValues(id: String): List<ParameterValue>?
+
+    @Query("SELECT value FROM ParameterValue WHERE pluginId = :pluginId AND paramName = :paramName")
+    suspend fun getParameterValue(pluginId: String, paramName: String): String?
+
+    @Query("INSERT INTO ParameterValue (pluginId, paramName, value) VALUES (:pluginId, :paramName, :value) ON CONFLICT(pluginId, paramName) DO UPDATE SET value = :value")
+    suspend fun setParameterValue(pluginId: String, paramName: String, value: String)
 }
\ No newline at end of file
diff --git a/app/src/main/java/net/pipe01/pinepartner/data/PluginParameter.kt b/app/src/main/java/net/pipe01/pinepartner/data/PluginParameter.kt
new file mode 100644
index 0000000..b2da68a
--- /dev/null
+++ b/app/src/main/java/net/pipe01/pinepartner/data/PluginParameter.kt
@@ -0,0 +1,10 @@
+package net.pipe01.pinepartner.data
+
+import androidx.room.Entity
+
+@Entity(primaryKeys = ["pluginId", "paramName"])
+data class ParameterValue(
+    val pluginId: String,
+    val paramName: String,
+    val value: String,
+)
diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/ImportPluginPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/ImportPluginPage.kt
index 0b4a2fa..a822452 100644
--- a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/ImportPluginPage.kt
+++ b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/ImportPluginPage.kt
@@ -222,6 +222,7 @@ private fun ImportStepPreview() {
                 sourceCode = "",
                 checksum = "",
                 permissions = Permission.entries.toSet(),
+                parameters = emptyList(),
                 downloadUrl = "",
                 enabled = false,
                 isBuiltIn = false,
diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt
index 8675323..2834f3a 100644
--- a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt
+++ b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt
@@ -4,12 +4,19 @@ import android.widget.Toast
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material3.Button
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
 import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -18,6 +25,7 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.alpha
 import androidx.compose.ui.graphics.Color
@@ -33,10 +41,14 @@ import kotlinx.coroutines.launch
 import net.pipe01.pinepartner.components.LoadingStandIn
 import net.pipe01.pinepartner.data.Plugin
 import net.pipe01.pinepartner.data.PluginDao
+import net.pipe01.pinepartner.scripting.BooleanType
 import net.pipe01.pinepartner.scripting.BuiltInPlugins
 import net.pipe01.pinepartner.scripting.EventSeverity
+import net.pipe01.pinepartner.scripting.IntegerType
 import net.pipe01.pinepartner.scripting.LogEvent
+import net.pipe01.pinepartner.scripting.Parameter
 import net.pipe01.pinepartner.scripting.Permission
+import net.pipe01.pinepartner.scripting.StringType
 import net.pipe01.pinepartner.scripting.downloadPlugin
 import net.pipe01.pinepartner.service.BackgroundService
 import java.time.ZoneOffset
@@ -53,10 +65,15 @@ fun PluginPage(
     val coroutineScope = rememberCoroutineScope()
 
     var plugin by remember { mutableStateOf<Plugin?>(null) }
+    var paramValues by remember { mutableStateOf<Map<String, String>?>(null) }
     val events = remember { mutableStateListOf<LogEvent>() }
 
     LaunchedEffect(id) {
         plugin = BuiltInPlugins.get(id) ?: pluginDao.getById(id) ?: throw IllegalArgumentException("Plugin not found")
+        paramValues = pluginDao
+            .getParameterValues(id)
+            ?.associateBy({ it.paramName }, { it.value })
+            ?: emptyMap()
 
         while (true) {
             val resp = backgroundService.getPluginEvents(id, events.lastOrNull()?.time?.toEpochSecond(ZoneOffset.UTC) ?: 0)
@@ -67,9 +84,10 @@ fun PluginPage(
         }
     }
 
-    LoadingStandIn(isLoading = plugin == null) {
+    LoadingStandIn(isLoading = plugin == null || paramValues == null) {
         Plugin(
             plugin = plugin!!,
+            paramValues = paramValues!!,
             events = events,
             onRemove = {
                 coroutineScope.launch {
@@ -99,6 +117,15 @@ fun PluginPage(
                 }
             },
             onViewCode = onViewCode,
+            onSetParameter = { name, value ->
+                val newParamValues = paramValues!!.toMutableMap()
+                newParamValues[name] = value
+                paramValues = newParamValues
+
+                coroutineScope.launch {
+                    pluginDao.setParameterValue(id, name, value)
+                }
+            }
         )
     }
 }
@@ -106,10 +133,12 @@ fun PluginPage(
 @Composable
 private fun Plugin(
     plugin: Plugin,
+    paramValues: Map<String, String> = emptyMap(),
     events: List<LogEvent>,
     onRemove: () -> Unit = { },
     onUpdate: () -> Unit = { },
     onViewCode: () -> Unit = { },
+    onSetParameter: (name: String, value: String) -> Unit = { _, _ -> },
 ) {
     Column(
         modifier = Modifier.padding(16.dp),
@@ -164,6 +193,18 @@ private fun Plugin(
             }
         }
 
+        Property(name = "Parameters") {
+            for (param in plugin.parameters) {
+                val value = paramValues[param.name] ?: param.defaultValue ?: continue
+
+                Parameter(
+                    param = param,
+                    value = value,
+                    onSetValue = { onSetParameter(param.name, it) }
+                )
+            }
+        }
+
         Spacer(modifier = Modifier.height(24.dp))
 
         Column {
@@ -214,6 +255,51 @@ private fun Property(name: String, value: @Composable () -> Unit) {
     }
 }
 
+@Composable
+private fun Parameter(param: Parameter, value: String, onSetValue: (String) -> Unit) {
+    Row(
+        modifier = Modifier.defaultMinSize(minHeight = 48.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        Text(
+            modifier = Modifier.weight(40f),
+            text = param.name,
+            style = MaterialTheme.typography.titleMedium,
+        )
+
+        Row(modifier = Modifier.weight(60f)) {
+            if (param.defaultValue != null) {
+                FilledIconButton(onClick = {
+                    onSetValue(param.defaultValue)
+                }) {
+                    Icon(Icons.Filled.Refresh, contentDescription = "Reset to default")
+                }
+            }
+
+            when (param.type) {
+                StringType -> TextField(
+                    value = StringType.unmarshal(value),
+                    onValueChange = { onSetValue(StringType.marshal(it)) },
+                    singleLine = true,
+                )
+
+                IntegerType -> TextField(
+                    value = IntegerType.unmarshal(value).toString(),
+                    onValueChange = { onSetValue(IntegerType.marshal(it.filter(Char::isDigit).toInt())) },
+                    singleLine = true,
+                )
+
+                BooleanType -> {
+                    Switch(
+                        checked = BooleanType.unmarshal(value),
+                        onCheckedChange = { onSetValue(BooleanType.marshal(it)) },
+                    )
+                }
+            }
+        }
+    }
+}
+
 @Preview(showBackground = true)
 @Composable
 fun PluginPreview() {
@@ -226,6 +312,23 @@ fun PluginPreview() {
             sourceCode = "",
             checksum = "",
             permissions = Permission.entries.toSet(),
+            parameters = listOf(
+                Parameter(
+                    name = "param1",
+                    type = StringType,
+                    defaultValue = "default",
+                ),
+                Parameter(
+                    name = "param2",
+                    type = IntegerType,
+                    defaultValue = "123",
+                ),
+                Parameter(
+                    name = "param3",
+                    type = BooleanType,
+                    defaultValue = "true",
+                ),
+            ),
             downloadUrl = "",
             enabled = true,
             isBuiltIn = false,
@@ -245,6 +348,7 @@ fun PluginPreviewNoPermissions() {
             author = "author",
             sourceCode = "",
             permissions = emptySet(),
+            parameters = emptyList(),
             checksum = "",
             downloadUrl = "",
             enabled = true,
@@ -265,6 +369,7 @@ fun PluginPreviewNoDescription() {
             author = "author",
             sourceCode = "",
             permissions = emptySet(),
+            parameters = emptyList(),
             checksum = "",
             downloadUrl = "",
             enabled = true,
diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt
index 725a0ca..c7717ce 100644
--- a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt
+++ b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt
@@ -200,6 +200,7 @@ private fun PluginItemPreview() {
                 sourceCode = "",
                 checksum = "",
                 permissions = emptySet(),
+                parameters = emptyList(),
                 downloadUrl = null,
                 enabled = i % 2 == 1,
                 isBuiltIn = i % 2 == 0,
diff --git a/app/src/main/java/net/pipe01/pinepartner/scripting/Parameter.kt b/app/src/main/java/net/pipe01/pinepartner/scripting/Parameter.kt
new file mode 100644
index 0000000..a3d38db
--- /dev/null
+++ b/app/src/main/java/net/pipe01/pinepartner/scripting/Parameter.kt
@@ -0,0 +1,75 @@
+package net.pipe01.pinepartner.scripting
+
+data class Parameter(
+    val name: String,
+    val type: ParameterType,
+    val defaultValue: String?,
+) {
+    companion object {
+        fun parse(str: String): Parameter? {
+            val parts = str.split(" ")
+
+            if (parts.size < 2 || parts.size > 3) {
+                return null
+            }
+
+            val name = parts[0]
+            if (!name.matches(Regex("^[a-zA-Z0-9_]+$"))) {
+                return null
+            }
+
+            return Parameter(
+                name = parts[0],
+                type = when (parts[1]) {
+                    "string" -> StringType
+                    "int" -> IntegerType
+                    "boolean" -> BooleanType
+                    else -> return null
+                },
+                defaultValue = parts.getOrNull(2)
+            )
+        }
+    }
+
+    override fun toString(): String {
+        val typeName = when (type) {
+            StringType -> "string"
+            IntegerType -> "int"
+            BooleanType -> "bool"
+            else -> throw IllegalStateException("Unknown type")
+        }
+
+        return "$name $typeName${if (defaultValue != null) " $defaultValue" else ""}"
+    }
+}
+
+interface ParameterType {
+    fun validate(value: Any): Boolean
+
+    fun marshal(value: Any): String
+    fun unmarshal(str: String): Any
+}
+
+object StringType : ParameterType {
+    override fun validate(value: Any) = value is String
+
+    override fun marshal(value: Any) = value as String
+
+    override fun unmarshal(str: String) = str
+}
+
+object IntegerType : ParameterType {
+    override fun validate(value: Any) = value is Int
+
+    override fun marshal(value: Any) = value.toString()
+
+    override fun unmarshal(str: String) = str.toInt()
+}
+
+object BooleanType : ParameterType {
+    override fun validate(value: Any) = value is Boolean
+
+    override fun marshal(value: Any) = value.toString()
+
+    override fun unmarshal(str: String) = str.toBoolean()
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/pipe01/pinepartner/scripting/Runner.kt b/app/src/main/java/net/pipe01/pinepartner/scripting/Runner.kt
index a7bbce0..701ed4f 100644
--- a/app/src/main/java/net/pipe01/pinepartner/scripting/Runner.kt
+++ b/app/src/main/java/net/pipe01/pinepartner/scripting/Runner.kt
@@ -17,6 +17,7 @@ import net.pipe01.pinepartner.scripting.api.HTTPService
 import net.pipe01.pinepartner.scripting.api.LocationService
 import net.pipe01.pinepartner.scripting.api.MediaService
 import net.pipe01.pinepartner.scripting.api.NotificationsService
+import net.pipe01.pinepartner.scripting.api.Parameters
 import net.pipe01.pinepartner.scripting.api.Require
 import net.pipe01.pinepartner.scripting.api.VolumeService
 import net.pipe01.pinepartner.scripting.api.WatchesService
@@ -31,8 +32,6 @@ import net.pipe01.pinepartner.service.DeviceManager
 import net.pipe01.pinepartner.service.NotificationsManager
 import org.mozilla.javascript.Context
 import org.mozilla.javascript.ContextFactory
-import org.mozilla.javascript.ErrorReporter
-import org.mozilla.javascript.EvaluatorException
 import org.mozilla.javascript.NativeConsole
 import org.mozilla.javascript.ScriptableObject
 import java.time.LocalDateTime
@@ -70,20 +69,6 @@ class Runner(val plugin: Plugin, deps: ScriptDependencies) {
             override fun contextCreated(cx: Context) {
                 cx.optimizationLevel = -1 // Disable code generation because Android doesn't support it
                 cx.languageVersion = Context.VERSION_ES6
-
-                cx.setErrorReporter(object : ErrorReporter {
-                    override fun warning(p0: String?, p1: String?, p2: Int, p3: String?, p4: Int) {
-                        TODO("Not yet implemented")
-                    }
-
-                    override fun error(p0: String?, p1: String?, p2: Int, p3: String?, p4: Int) {
-                        TODO("Not yet implemented")
-                    }
-
-                    override fun runtimeError(p0: String?, p1: String?, p2: Int, p3: String?, p4: Int): EvaluatorException {
-                        TODO("Not yet implemented")
-                    }
-                })
             }
 
             override fun contextReleased(cx: Context) {
@@ -112,6 +97,7 @@ class Runner(val plugin: Plugin, deps: ScriptDependencies) {
             ScriptableObject.defineClass(scope, PlaybackStateAdapter::class.java)
             ScriptableObject.defineClass(scope, LocationAdapter::class.java)
 
+            ScriptableObject.putProperty(scope, "params", Parameters(scope, plugin.name, plugin.parameters, deps.db))
             ScriptableObject.putProperty(scope, "require", Require(deps, plugin.permissions, contextFactory, dispatcher, ::addEvent))
 
             NativeConsole.init(scope, true, ConsolePrinter(::addEvent))
diff --git a/app/src/main/java/net/pipe01/pinepartner/scripting/api/Parameters.kt b/app/src/main/java/net/pipe01/pinepartner/scripting/api/Parameters.kt
new file mode 100644
index 0000000..88764c7
--- /dev/null
+++ b/app/src/main/java/net/pipe01/pinepartner/scripting/api/Parameters.kt
@@ -0,0 +1,80 @@
+package net.pipe01.pinepartner.scripting.api
+
+import kotlinx.coroutines.runBlocking
+import net.pipe01.pinepartner.data.AppDatabase
+import net.pipe01.pinepartner.scripting.Parameter
+import org.mozilla.javascript.Context
+import org.mozilla.javascript.ScriptRuntime
+import org.mozilla.javascript.Scriptable
+import org.mozilla.javascript.Undefined
+
+class Parameters(
+    private var parentScope: Scriptable?,
+    private val pluginName: String,
+    private val parameters: List<Parameter>,
+    private val db: AppDatabase,
+) : Scriptable {
+    private fun throwReadOnly(): Nothing {
+        throw ScriptRuntime.throwError(Context.getCurrentContext(), this, "Parameters are read-only")
+    }
+
+    override fun getClassName() = "Parameters"
+
+    override fun get(p0: String?, p1: Scriptable?): Any {
+        val param = parameters.find { it.name == p0 } ?: return Undefined.instance
+
+        return runBlocking {
+            val str = db.pluginDao().getParameterValue(pluginName, p0!!)
+
+            if (str == null) {
+                if (param.defaultValue != null)
+                    param.type.unmarshal(param.defaultValue)
+                else
+                    Undefined.instance
+            } else {
+                param.type.unmarshal(str)
+            }
+        }
+    }
+
+    override fun get(p0: Int, p1: Scriptable?): Any = Undefined.instance
+
+    override fun has(p0: String?, p1: Scriptable?) = p0 != null && parameters.any { it.name == p0 }
+
+    override fun has(p0: Int, p1: Scriptable?) = false
+
+    override fun put(p0: String?, p1: Scriptable?, p2: Any?) = throwReadOnly()
+
+    override fun put(p0: Int, p1: Scriptable?, p2: Any?) = throwReadOnly()
+
+    override fun delete(p0: String?) = throwReadOnly()
+
+    override fun delete(p0: Int) = throwReadOnly()
+
+    override fun getPrototype(): Scriptable {
+        TODO("Not yet implemented")
+    }
+
+    override fun setPrototype(p0: Scriptable?) {
+        TODO("Not yet implemented")
+    }
+
+    override fun getParentScope() = parentScope
+
+    override fun setParentScope(p0: Scriptable?) {
+        parentScope = p0
+    }
+
+    override fun getIds(): Array<Any> {
+        TODO("Not yet implemented")
+    }
+
+    override fun getDefaultValue(p0: Class<*>?): Any {
+        TODO("Not yet implemented")
+    }
+
+    override fun hasInstance(p0: Scriptable?): Boolean {
+        TODO("Not yet implemented")
+    }
+
+}
\ No newline at end of file