feat: add plugin system (#1034)
* feat: add plugin system * fix db docker build * fix hammerhead readme, add strava subscription news to docs * fixes and sdk improvements * fix: reduce Meilisearch load, debounce federation sync (#1012) * optimize meili trail index * several fixes --------- Co-authored-by: Flomp <Flomp@users.noreply.github.com> * Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032) Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0. - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte) --- updated-dependencies: - dependency-name: svelte dependency-version: 5.56.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Flomp <Flomp@users.noreply.github.com> * Release v0.19.2 (#1035) * chore: release v0.19.2 * add changelog --------- Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com> Co-authored-by: Christian Beutel <> * speed up plugin sync and several small fixes * concepts for security improvements and process stability * improve concept * security concept implemented * remove insecure TLS * worker concept implemented * fixes and cleanup * fixes * docu * mermaid, namings * WASM plugin host improvements, plugin logging * fix db migration * Improve plugin config and category mapping UI * fixes * further fixes * remove manual test sync * fix db migration and strava mapping * type added, UI improvements * fix plugin card toggle clickable area * optimize synch status card layout * plugin type 'trails' instead of 'integration' * session auth validation in UI * fix komoot date and waypoints * improve category mapping * fix send to hammerhead: trail name * plugin setup error handling improved * fix review findings * re-mapping added * rename remote_category --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Flomp <Flomp@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
This commit is contained in:
536
db/migrations/1780000002_plugin_instances.go
Normal file
536
db/migrations/1780000002_plugin_instances.go
Normal file
@@ -0,0 +1,536 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
// Create plugin_instances collection
|
||||
jsonData := `{
|
||||
"createRule": "@request.auth.id = user.id",
|
||||
"deleteRule": "@request.auth.id = user.id",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text430001001",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"hidden": false,
|
||||
"id": "relation430001002",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "user",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text430001003",
|
||||
"max": 64,
|
||||
"min": 1,
|
||||
"name": "plugin_id",
|
||||
"pattern": "^[a-z0-9][a-z0-9_-]*$",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool430001004",
|
||||
"name": "enabled",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json430001005",
|
||||
"maxSize": 2000000,
|
||||
"name": "auth",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json430001006",
|
||||
"maxSize": 2000000,
|
||||
"name": "config",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json430001007",
|
||||
"maxSize": 2000000,
|
||||
"name": "state",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "select430001008",
|
||||
"maxSelect": 1,
|
||||
"name": "status",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"configured",
|
||||
"needs_auth",
|
||||
"needs_reauth",
|
||||
"syncing",
|
||||
"rate_limited",
|
||||
"unavailable",
|
||||
"unsupported_protocol",
|
||||
"error",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json430001009",
|
||||
"maxSize": 2000000,
|
||||
"name": "last_error",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "date430001010",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "last_sync_at",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "date430001011",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "retry_not_before",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate430001012",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate430001013",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pbc_430001000",
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX ` + "`" + `idx_plugin_instances_user_plugin_id` + "`" + ` ON ` + "`" + `plugin_instances` + "`" + ` (` + "`" + `user` + "`" + `, ` + "`" + `plugin_id` + "`" + `)"
|
||||
],
|
||||
"listRule": "@request.auth.id = user.id",
|
||||
"name": "plugin_instances",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.id = user.id",
|
||||
"viewRule": "@request.auth.id = user.id"
|
||||
}`
|
||||
|
||||
if _, err := app.FindCollectionByNameOrId("pbc_430001000"); err != nil {
|
||||
collection := &core.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), collection); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateLegacyIntegrationsToPluginInstances(app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove the previous hard-coded provider settings collection after
|
||||
// migrating its configuration into plugin_instances. The migration is
|
||||
// data-only and does not require the corresponding plugin bundles to be
|
||||
// installed.
|
||||
if legacyCollection, err := app.FindCollectionByNameOrId("integrations"); err == nil {
|
||||
if err := app.Delete(legacyCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Add user field to trail_external_reference and update index to be user-scoped
|
||||
refCollection, err := app.FindCollectionByNameOrId("trail_external_reference")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if refCollection.Fields.GetByName("user") == nil {
|
||||
if err := refCollection.Fields.AddMarshaledJSONAt(2, []byte(`{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"hidden": false,
|
||||
"id": "relation430002001",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "user",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Replace the global unique (provider, external_id) index with a
|
||||
// user-scoped one so the same external trail can be imported by
|
||||
// multiple users. This must be managed via the collection metadata
|
||||
// (not a raw DROP INDEX), otherwise app.Save would recreate the old
|
||||
// index from the still-present metadata entry.
|
||||
keptIndexes := refCollection.Indexes[:0]
|
||||
for _, idx := range refCollection.Indexes {
|
||||
if strings.Contains(idx, "idx_trail_external_reference_provider_external_id") {
|
||||
continue
|
||||
}
|
||||
keptIndexes = append(keptIndexes, idx)
|
||||
}
|
||||
refCollection.Indexes = append(keptIndexes,
|
||||
"CREATE UNIQUE INDEX `idx_trail_external_reference_user_provider_external_id` ON `trail_external_reference` (`user`, `provider`, `external_id`)",
|
||||
)
|
||||
|
||||
if err := app.Save(refCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refs, err := app.FindAllRecords("trail_external_reference")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ref := range refs {
|
||||
trailID := ref.GetString("trail")
|
||||
if trailID == "" {
|
||||
continue
|
||||
}
|
||||
trail, err := app.FindRecordById("trails", trailID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
actor, err := app.FindRecordById("activitypub_actors", trail.GetString("author"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
userID := actor.GetString("user")
|
||||
if userID == "" {
|
||||
continue
|
||||
}
|
||||
ref.Set("user", userID)
|
||||
if err := app.Save(ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func migrateLegacyIntegrationsToPluginInstances(app core.App) error {
|
||||
if _, err := app.FindCollectionByNameOrId("integrations"); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
records, err := app.FindAllRecords("integrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
userID := record.GetString("user")
|
||||
if userID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if raw := legacyJSONObject(record.GetString("strava")); legacyHasValue(raw["clientId"]) {
|
||||
auth := legacyPick(raw, "clientId", "clientSecret", "accessToken", "refreshToken", "expiresAt", "tokenType", "scope")
|
||||
legacyNormalizeStravaAuth(auth)
|
||||
hostConfig := legacyPick(raw, "privacy", "merge")
|
||||
hostConfig["planned"] = legacyBool(raw["routes"])
|
||||
hostConfig["completed"] = legacyBool(raw["activities"])
|
||||
config := legacyNamespacedPluginConfig(
|
||||
legacyPick(raw, "after"),
|
||||
hostConfig,
|
||||
)
|
||||
if err := saveLegacyMappedPluginInstance(app, userID, "strava", auth, config, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if raw := legacyJSONObject(record.GetString("komoot")); legacyHasValue(raw["email"]) {
|
||||
auth := legacyPick(raw, "email", "password")
|
||||
config := legacyNamespacedPluginConfig(
|
||||
legacyPick(raw, "after"),
|
||||
legacyPick(raw, "planned", "completed", "privacy", "merge"),
|
||||
)
|
||||
if err := saveLegacyMappedPluginInstance(app, userID, "komoot", auth, config, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if raw := legacyJSONObject(record.GetString("hammerhead")); legacyHasValue(raw["email"]) {
|
||||
auth := legacyPick(raw, "email", "password")
|
||||
config := legacyNamespacedPluginConfig(
|
||||
legacyPick(raw, "after"),
|
||||
legacyPick(raw, "planned", "completed", "privacy", "merge"),
|
||||
)
|
||||
if err := saveLegacyMappedPluginInstance(app, userID, "hammerhead", auth, config, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func legacyNamespacedPluginConfig(pluginConfig map[string]any, hostConfig map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"plugin": nilMap(pluginConfig),
|
||||
"host": nilMap(hostConfig),
|
||||
}
|
||||
}
|
||||
|
||||
func saveLegacyMappedPluginInstance(app core.App, userID string, pluginID string, auth map[string]any, config map[string]any, raw map[string]any) error {
|
||||
enabled := legacyBool(raw["active"]) && legacyPluginAuthComplete(pluginID, auth)
|
||||
return saveLegacyPluginInstance(app, legacyPluginInstance{
|
||||
UserID: userID,
|
||||
PluginID: pluginID,
|
||||
Enabled: enabled,
|
||||
Auth: auth,
|
||||
Config: config,
|
||||
State: map[string]any{},
|
||||
Status: legacyPluginInstanceStatus(pluginID, auth, enabled, ""),
|
||||
LastError: map[string]any{},
|
||||
})
|
||||
}
|
||||
|
||||
type legacyPluginInstance struct {
|
||||
UserID string
|
||||
PluginID string
|
||||
Enabled bool
|
||||
Auth map[string]any
|
||||
Config map[string]any
|
||||
State map[string]any
|
||||
Status string
|
||||
LastError map[string]any
|
||||
LastSyncAt string
|
||||
RetryNotBefore string
|
||||
}
|
||||
|
||||
func saveLegacyPluginInstance(app core.App, instance legacyPluginInstance) error {
|
||||
if instance.UserID == "" || instance.PluginID == "" {
|
||||
return nil
|
||||
}
|
||||
existing, _ := app.FindFirstRecordByFilter(
|
||||
"plugin_instances",
|
||||
"user={:user} && plugin_id={:plugin_id}",
|
||||
dbx.Params{"user": instance.UserID, "plugin_id": instance.PluginID},
|
||||
)
|
||||
if existing != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
authJSON, err := json.Marshal(nilMap(instance.Auth))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configJSON, err := json.Marshal(nilMap(instance.Config))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stateJSON, err := json.Marshal(nilMap(instance.State))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastErrorJSON, err := json.Marshal(nilMap(instance.LastError))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status := instance.Status
|
||||
if status == "" {
|
||||
status = legacyPluginInstanceStatus(instance.PluginID, instance.Auth, instance.Enabled, "")
|
||||
}
|
||||
|
||||
now := types.NowDateTime().String()
|
||||
_, err = app.DB().Insert("plugin_instances", dbx.Params{
|
||||
"id": security.RandomStringWithAlphabet(15, "abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
"user": instance.UserID,
|
||||
"plugin_id": instance.PluginID,
|
||||
"enabled": instance.Enabled,
|
||||
"auth": string(authJSON),
|
||||
"config": string(configJSON),
|
||||
"state": string(stateJSON),
|
||||
"status": status,
|
||||
"last_error": string(lastErrorJSON),
|
||||
"last_sync_at": instance.LastSyncAt,
|
||||
"retry_not_before": instance.RetryNotBefore,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
}).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
func legacyPluginInstanceStatus(pluginID string, auth map[string]any, enabled bool, previous string) string {
|
||||
if !legacyPluginAuthComplete(pluginID, auth) {
|
||||
return "needs_auth"
|
||||
}
|
||||
if !enabled {
|
||||
return "disabled"
|
||||
}
|
||||
switch previous {
|
||||
case "configured", "needs_reauth", "syncing", "rate_limited", "unavailable", "unsupported_protocol", "error":
|
||||
return previous
|
||||
default:
|
||||
return "configured"
|
||||
}
|
||||
}
|
||||
|
||||
func legacyPluginAuthComplete(pluginID string, auth map[string]any) bool {
|
||||
switch pluginID {
|
||||
case "strava":
|
||||
return legacyHasValue(auth["clientId"]) && legacyHasValue(auth["clientSecret"]) && legacyHasValue(auth["refreshToken"])
|
||||
case "komoot", "hammerhead":
|
||||
return legacyHasValue(auth["email"]) && legacyHasValue(auth["password"])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func legacyNormalizeStravaAuth(auth map[string]any) {
|
||||
legacyStringAuthFields(auth, "clientId", "clientSecret", "accessToken", "refreshToken", "tokenType", "scope")
|
||||
switch value := auth["expiresAt"].(type) {
|
||||
case float64:
|
||||
if value > 0 {
|
||||
auth["expiresAt"] = time.Unix(int64(value), 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
case int64:
|
||||
if value > 0 {
|
||||
auth["expiresAt"] = time.Unix(value, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
case int:
|
||||
if value > 0 {
|
||||
auth["expiresAt"] = time.Unix(int64(value), 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func legacyStringAuthFields(auth map[string]any, keys ...string) {
|
||||
for _, key := range keys {
|
||||
switch value := auth[key].(type) {
|
||||
case string:
|
||||
// already normalized
|
||||
case float64:
|
||||
auth[key] = strconv.FormatFloat(value, 'f', -1, 64)
|
||||
case int64:
|
||||
auth[key] = strconv.FormatInt(value, 10)
|
||||
case int:
|
||||
auth[key] = strconv.Itoa(value)
|
||||
case nil:
|
||||
// leave absent/null values untouched so completeness checks still fail
|
||||
default:
|
||||
auth[key] = fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func legacyJSONObject(raw string) map[string]any {
|
||||
if raw == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil || data == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func legacyPick(src map[string]any, keys ...string) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, key := range keys {
|
||||
if value, ok := src[key]; ok && value != nil {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func legacyHasValue(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
return strings.TrimSpace(v) != ""
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func legacyBool(value any) bool {
|
||||
b, _ := value.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func nilMap(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
198
db/migrations/1780000004_plugin_system.go
Normal file
198
db/migrations/1780000004_plugin_system.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
return createInstalledPluginsCollection(app)
|
||||
}, func(app core.App) error {
|
||||
if collection, err := app.FindCollectionByNameOrId("installed_plugins"); err == nil {
|
||||
if err := app.Delete(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func createInstalledPluginsCollection(app core.App) error {
|
||||
if _, err := app.FindCollectionByNameOrId("installed_plugins"); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData := `{
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "textplginsid01",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "textplginpid1",
|
||||
"max": 128,
|
||||
"min": 1,
|
||||
"name": "plugin_id",
|
||||
"pattern": "^[a-z0-9][a-z0-9_-]*$",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "textplginname",
|
||||
"max": 256,
|
||||
"min": 1,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": true,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "selectplgtype",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": ["trails"]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "textplginvers",
|
||||
"max": 64,
|
||||
"min": 1,
|
||||
"name": "version",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "textplginrunt",
|
||||
"max": 32,
|
||||
"min": 1,
|
||||
"name": "runtime",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "textplginpath",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "path",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "jsonplginman",
|
||||
"maxSize": 2000000,
|
||||
"name": "manifest",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "jsonplgincfg",
|
||||
"maxSize": 2000000,
|
||||
"name": "config",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "selectplginst",
|
||||
"maxSelect": 1,
|
||||
"name": "status",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": ["available", "disabled", "error"]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "textplginerr",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "error",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autoplgcreate",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autoplgupdate",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pbc_430002000",
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX ` + "`" + `idx_installed_plugins_plugin_id` + "`" + ` ON ` + "`" + `installed_plugins` + "`" + ` (` + "`" + `plugin_id` + "`" + `)"
|
||||
],
|
||||
"listRule": null,
|
||||
"name": "installed_plugins",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
}`
|
||||
|
||||
collection := &core.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), collection); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.Save(collection)
|
||||
}
|
||||
46
db/migrations/1780000005_add_other_category.go
Normal file
46
db/migrations/1780000005_add_other_category.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
categories, err := app.FindAllRecords("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(categories) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
existing, _ := app.FindFirstRecordByData("categories", "name", "Other")
|
||||
if existing != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("name", "Other")
|
||||
record.Set("settings", map[string]any{
|
||||
"wp_merge_enabled": true,
|
||||
"wp_merge_radius": 50,
|
||||
})
|
||||
if file, err := filesystem.NewFileFromPath("migrations/initial_data/other.jpg"); err == nil {
|
||||
record.Set("img", file)
|
||||
}
|
||||
return app.Save(record)
|
||||
}, func(app core.App) error {
|
||||
record, _ := app.FindFirstRecordByData("categories", "name", "Other")
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
return app.Delete(record)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
const providerBackupColumn1780000006 = "provider_backup_1780000006"
|
||||
const userPluginIndex1780000006 = "CREATE INDEX `idx_trail_external_reference_user_plugin_id` ON `trail_external_reference` (`user`, `plugin_id`)"
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
if err := backupProviderColumn1780000006(app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("trail_external_reference")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop+re-add (with a new id) blanks the provider column, so the
|
||||
// provider-scoped unique indexes must not be rebuilt until the values
|
||||
// have been restored, otherwise a cross-provider external_id clash would
|
||||
// fail index creation and abort the migration.
|
||||
removedIndexes := stripProviderIndexes1780000006(collection)
|
||||
|
||||
collection.Fields.RemoveByName("provider")
|
||||
if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text420001002",
|
||||
"max": 128,
|
||||
"min": 1,
|
||||
"name": "provider",
|
||||
"pattern": "^[a-z0-9][a-z0-9_-]*$",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if collection.Fields.GetByName("plugin_id") == nil {
|
||||
if err := collection.Fields.AddMarshaledJSONAt(4, []byte(`{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "textpluginref",
|
||||
"max": 64,
|
||||
"min": 0,
|
||||
"name": "plugin_id",
|
||||
"pattern": "^[a-z0-9][a-z0-9_-]*$",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if collection.Fields.GetByName("provider_category") == nil {
|
||||
if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "txtrmtecat01",
|
||||
"max": 255,
|
||||
"min": 0,
|
||||
"name": "provider_category",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if collection.Fields.GetByName("provider_category_checked_at") == nil {
|
||||
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "datermtecat1",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "provider_category_checked_at",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := restoreProviderColumn1780000006(app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection.Indexes = append(collection.Indexes, removedIndexes...)
|
||||
if !hasIndex1780000006(collection, userPluginIndex1780000006) {
|
||||
collection.Indexes = append(collection.Indexes, userPluginIndex1780000006)
|
||||
}
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refs, err := app.FindAllRecords("trail_external_reference")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if ref.GetString("plugin_id") != "" {
|
||||
continue
|
||||
}
|
||||
ref.Set("plugin_id", ref.GetString("provider"))
|
||||
if err := app.Save(ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
if err := backupProviderColumn1780000006(app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("trail_external_reference")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
removedIndexes := stripProviderIndexes1780000006(collection)
|
||||
removeIndex1780000006(collection, userPluginIndex1780000006)
|
||||
|
||||
collection.Fields.RemoveByName("provider_category_checked_at")
|
||||
collection.Fields.RemoveByName("provider_category")
|
||||
collection.Fields.RemoveByName("plugin_id")
|
||||
collection.Fields.RemoveByName("provider")
|
||||
if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "select420001002",
|
||||
"maxSelect": 1,
|
||||
"name": "provider",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"strava",
|
||||
"komoot",
|
||||
"hammerhead"
|
||||
]
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := restoreProviderColumn1780000006(app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection.Indexes = append(collection.Indexes, removedIndexes...)
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
|
||||
// stripProviderIndexes1780000006 removes the provider-scoped indexes from the
|
||||
// collection metadata and returns them so they can be re-added once the
|
||||
// provider values have been restored. PocketBase rebuilds indexes from the
|
||||
// collection metadata on every save; leaving the provider indexes in place
|
||||
// while the column is transiently empty risks a unique-constraint failure.
|
||||
func stripProviderIndexes1780000006(collection *core.Collection) []string {
|
||||
kept := make([]string, 0, len(collection.Indexes))
|
||||
removed := make([]string, 0)
|
||||
for _, idx := range collection.Indexes {
|
||||
if strings.Contains(idx, "`provider`") {
|
||||
removed = append(removed, idx)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, idx)
|
||||
}
|
||||
collection.Indexes = kept
|
||||
return removed
|
||||
}
|
||||
|
||||
func hasIndex1780000006(collection *core.Collection, index string) bool {
|
||||
for _, existing := range collection.Indexes {
|
||||
if existing == index {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func removeIndex1780000006(collection *core.Collection, index string) {
|
||||
indexes := collection.Indexes[:0]
|
||||
for _, existing := range collection.Indexes {
|
||||
if existing == index {
|
||||
continue
|
||||
}
|
||||
indexes = append(indexes, existing)
|
||||
}
|
||||
collection.Indexes = indexes
|
||||
}
|
||||
|
||||
func backupProviderColumn1780000006(app core.App) error {
|
||||
exists, err := columnExists1780000006(app, providerBackupColumn1780000006)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := app.DB().
|
||||
NewQuery("ALTER TABLE trail_external_reference ADD COLUMN " + providerBackupColumn1780000006 + " TEXT DEFAULT '' NOT NULL").
|
||||
Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = app.DB().
|
||||
NewQuery("UPDATE trail_external_reference SET " + providerBackupColumn1780000006 + " = provider").
|
||||
Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
func restoreProviderColumn1780000006(app core.App) error {
|
||||
if _, err := app.DB().
|
||||
NewQuery("UPDATE trail_external_reference SET provider = " + providerBackupColumn1780000006).
|
||||
Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := app.DB().DropColumn("trail_external_reference", providerBackupColumn1780000006).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
func columnExists1780000006(app core.App, column string) (bool, error) {
|
||||
columns, err := app.TableColumns("trail_external_reference")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, existing := range columns {
|
||||
if existing == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
BIN
db/migrations/initial_data/other.jpg
Normal file
BIN
db/migrations/initial_data/other.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 432 KiB |
Reference in New Issue
Block a user