feat: WO-PS-009 SDK plugin types
- plugin/types.go: Pool, EmailSender, JobHandlerFunc, Dependency, AdminPage, AIAction, MasterPageDefinition, status/source constants, MediaAnalyzedEvent, ModerationDecisionEvent, MediaHooksProvider, DirectoryExtensions - plugin/deps.go: ServiceDeps with typed capability interfaces (Content, Settings, Gating, Crypto, ToolRegistry, JobRunner, EmbeddingService, RAGService) - plugin/registration.go: PluginRegistration, RegisterFunc - plugin/service.go: ConnectServiceBinding with generics, ServiceMount, ServiceRegistration - plugin/provisioner.go: Provisioner interface with all config types - plugin/css_manifest.go: CSSManifest, MergedCSSManifest, MergeCSSManifests - plugin/version.go: ParseModVersion, CompareVersions - plugin/topo_sort.go: TopologicalSort (Kahn's algorithm) - plugin/block_registry.go: PluginBlockRegistry (auto-prefixing wrapper) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
79c558a968
commit
7c20538a4e
10
go.mod
10
go.mod
@ -3,6 +3,16 @@ module git.dev.alexdunmow.com/ninja/core
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
connectrpc.com/connect v1.19.2
|
||||||
github.com/a-h/templ v0.3.1001
|
github.com/a-h/templ v0.3.1001
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
|
golang.org/x/mod v0.27.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
32
go.sum
32
go.sum
@ -1,6 +1,38 @@
|
|||||||
|
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
|
||||||
|
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||||
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
|
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
|
||||||
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
32
plugin/block_registry.go
Normal file
32
plugin/block_registry.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/ninja/core/blocks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginBlockRegistry wraps a BlockRegistry to auto-prefix block keys with the plugin name.
|
||||||
|
type PluginBlockRegistry struct {
|
||||||
|
inner blocks.BlockRegistry
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPluginBlockRegistry creates a block registry that auto-prefixes keys.
|
||||||
|
func NewPluginBlockRegistry(inner blocks.BlockRegistry, pluginName string) *PluginBlockRegistry {
|
||||||
|
return &PluginBlockRegistry{inner: inner, prefix: pluginName}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginBlockRegistry) Register(meta blocks.BlockMeta, fn blocks.BlockFunc) {
|
||||||
|
meta.Key = r.prefix + ":" + meta.Key
|
||||||
|
meta.Source = r.prefix
|
||||||
|
r.inner.Register(meta, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginBlockRegistry) RegisterTemplateOverride(templateKey, blockKey string, fn blocks.BlockFunc) {
|
||||||
|
r.inner.RegisterTemplateOverride(templateKey, blockKey, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginBlockRegistry) LoadSchemasFromFS(fsys fs.FS) error {
|
||||||
|
return r.inner.LoadSchemasFromFS(fsys)
|
||||||
|
}
|
||||||
44
plugin/css_manifest.go
Normal file
44
plugin/css_manifest.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "maps"
|
||||||
|
|
||||||
|
// CSSManifest declares a plugin's CSS dependencies and customizations.
|
||||||
|
type CSSManifest struct {
|
||||||
|
NPMPackages map[string]string `json:"npm_packages,omitempty"`
|
||||||
|
CSSDirectives []string `json:"css_directives,omitempty"`
|
||||||
|
InputCSSAppend string `json:"input_css_append,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergedCSSManifest aggregates CSS manifests from all plugins.
|
||||||
|
type MergedCSSManifest struct {
|
||||||
|
NPMPackages map[string]string `json:"npm_packages"`
|
||||||
|
CSSDirectives []string `json:"css_directives"`
|
||||||
|
InputCSSAppend string `json:"input_css_append"`
|
||||||
|
Sources []string `json:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeCSSManifests combines multiple plugin CSS manifests into one.
|
||||||
|
func MergeCSSManifests(manifests map[string]*CSSManifest) *MergedCSSManifest {
|
||||||
|
merged := &MergedCSSManifest{
|
||||||
|
NPMPackages: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, m := range manifests {
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.Sources = append(merged.Sources, name)
|
||||||
|
maps.Copy(merged.NPMPackages, m.NPMPackages)
|
||||||
|
merged.CSSDirectives = append(merged.CSSDirectives, m.CSSDirectives...)
|
||||||
|
|
||||||
|
if m.InputCSSAppend != "" {
|
||||||
|
if merged.InputCSSAppend != "" {
|
||||||
|
merged.InputCSSAppend += "\n"
|
||||||
|
}
|
||||||
|
merged.InputCSSAppend += "/* === Plugin: " + name + " === */\n"
|
||||||
|
merged.InputCSSAppend += m.InputCSSAppend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
65
plugin/deps.go
Normal file
65
plugin/deps.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
"git.dev.alexdunmow.com/ninja/core/ai"
|
||||||
|
"git.dev.alexdunmow.com/ninja/core/content"
|
||||||
|
"git.dev.alexdunmow.com/ninja/core/crypto"
|
||||||
|
"git.dev.alexdunmow.com/ninja/core/gating"
|
||||||
|
"git.dev.alexdunmow.com/ninja/core/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceDeps provides dependencies that plugins need for RPC service handlers.
|
||||||
|
type ServiceDeps struct {
|
||||||
|
// Capability interfaces — typed access to CMS functionality
|
||||||
|
Content content.Content
|
||||||
|
Settings settings.Settings
|
||||||
|
Gating gating.Gating
|
||||||
|
Crypto crypto.Crypto
|
||||||
|
|
||||||
|
// Database — for plugin's own sqlc queries
|
||||||
|
Pool Pool
|
||||||
|
|
||||||
|
// RPC interceptors — core-provided Connect handler options
|
||||||
|
Interceptors connect.Option
|
||||||
|
|
||||||
|
// Site configuration
|
||||||
|
MediaPath string
|
||||||
|
AppURL string
|
||||||
|
|
||||||
|
// AI
|
||||||
|
ToolRegistry ai.ToolRegistry
|
||||||
|
AITextCall func(ctx context.Context, taskKey, systemPrompt, userMessage string) (string, error)
|
||||||
|
|
||||||
|
// Email
|
||||||
|
EmailSender EmailSender
|
||||||
|
|
||||||
|
// Extension points — typed as narrow interfaces where possible
|
||||||
|
JobRunner JobRunner
|
||||||
|
EmbeddingService EmbeddingService
|
||||||
|
RAGService RAGService
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobRunner submits background jobs for async processing.
|
||||||
|
type JobRunner interface {
|
||||||
|
Submit(ctx context.Context, jobType string, config []byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddingService generates and manages text embeddings.
|
||||||
|
type EmbeddingService interface {
|
||||||
|
GenerateEmbedding(ctx context.Context, text string) ([]float32, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAGService provides retrieval-augmented generation for AI agents.
|
||||||
|
type RAGService interface {
|
||||||
|
Query(ctx context.Context, query string, limit int) ([]RAGResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAGResult is a single result from a RAG query.
|
||||||
|
type RAGResult struct {
|
||||||
|
Content string
|
||||||
|
Score float64
|
||||||
|
Metadata map[string]string
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
package plugin
|
|
||||||
107
plugin/provisioner.go
Normal file
107
plugin/provisioner.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provisioner allows plugins to ensure required resources exist on startup.
|
||||||
|
// All methods are idempotent — they check first and only create if missing.
|
||||||
|
type Provisioner interface {
|
||||||
|
EnsureDataTable(config DataTableConfig) error
|
||||||
|
MergeSiteSettings(defaults map[string]any) error
|
||||||
|
EnsureSetting(key string, defaultValue map[string]any) error
|
||||||
|
EnsurePage(config PageConfig) error
|
||||||
|
OverrideSiteSettings(overrides map[string]any) error
|
||||||
|
EnsureMenuItem(menuName string, config MenuItemConfig) error
|
||||||
|
RegisterEmbeddingConfig(config EmbeddingConfigDef) error
|
||||||
|
EnsureEmbed(config EmbedConfig) error
|
||||||
|
EnsureJobSchedule(config JobScheduleConfig) error
|
||||||
|
UpdateDataTableRowField(ctx context.Context, rowID uuid.UUID, fieldKey string, value map[string]any) error
|
||||||
|
DisableOrphanedJobSchedules(registeredTypes []string) error
|
||||||
|
EnsurePlugin(name string) error
|
||||||
|
EnsureCustomColor(config CustomColorConfig) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataTableConfig defines a data table to provision.
|
||||||
|
type DataTableConfig struct {
|
||||||
|
Key string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Schema json.RawMessage
|
||||||
|
PrimaryKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageConfig defines a page to provision.
|
||||||
|
type PageConfig struct {
|
||||||
|
Slug string
|
||||||
|
ParentSlug string
|
||||||
|
Title string
|
||||||
|
TemplateKey string
|
||||||
|
Blocks []PageBlockConfig
|
||||||
|
DetailSourceType string
|
||||||
|
DetailSourceKey string
|
||||||
|
DetailSlugField string
|
||||||
|
ReconcileBlocks bool
|
||||||
|
ReconcileTemplate bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageBlockConfig defines a block within a provisioned page.
|
||||||
|
type PageBlockConfig struct {
|
||||||
|
BlockKey string
|
||||||
|
Title string
|
||||||
|
Content map[string]any
|
||||||
|
HtmlContent *string
|
||||||
|
Slot string
|
||||||
|
SortOrder int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddingConfigDef defines embedding text generation for a data table.
|
||||||
|
type EmbeddingConfigDef struct {
|
||||||
|
TableKey string
|
||||||
|
TextTemplate string
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuItemConfig defines a menu item to provision.
|
||||||
|
type MenuItemConfig struct {
|
||||||
|
Label string
|
||||||
|
URL string
|
||||||
|
PageSlug string
|
||||||
|
SortOrder int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedConfig defines an embeddable component template.
|
||||||
|
type EmbedConfig struct {
|
||||||
|
Key string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Icon string
|
||||||
|
LabelField string
|
||||||
|
Template string
|
||||||
|
RenderFunc func(ctx context.Context, content map[string]any) string
|
||||||
|
DataSource EmbedDataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedDataSource specifies where embed data comes from.
|
||||||
|
type EmbedDataSource struct {
|
||||||
|
Type string
|
||||||
|
TableKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobScheduleConfig defines a cron schedule for a background job.
|
||||||
|
type JobScheduleConfig struct {
|
||||||
|
JobType string
|
||||||
|
CronExpression string
|
||||||
|
Config json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomColorConfig defines a custom theme color variable.
|
||||||
|
type CustomColorConfig struct {
|
||||||
|
Name string
|
||||||
|
LightValue string
|
||||||
|
DarkValue string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
44
plugin/registration.go
Normal file
44
plugin/registration.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/ninja/core/blocks"
|
||||||
|
"git.dev.alexdunmow.com/ninja/core/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterFunc is the function signature for plugin registration.
|
||||||
|
// Plugins register their templates and blocks through the provided registries.
|
||||||
|
type RegisterFunc func(tr templates.TemplateRegistry, br blocks.BlockRegistry) error
|
||||||
|
|
||||||
|
// PluginRegistration defines a compiled-in plugin's entry points.
|
||||||
|
type PluginRegistration struct {
|
||||||
|
Name string
|
||||||
|
|
||||||
|
Register RegisterFunc
|
||||||
|
RegisterWithProvisioner func(tr templates.TemplateRegistry, br blocks.BlockRegistry, p Provisioner) error
|
||||||
|
|
||||||
|
Assets func() http.Handler
|
||||||
|
Schemas func() fs.FS
|
||||||
|
SettingsSchema func() []byte
|
||||||
|
ThemePresets func() []byte
|
||||||
|
BundledFonts func() []byte
|
||||||
|
MasterPages func() []MasterPageDefinition
|
||||||
|
|
||||||
|
HTTPHandler func(deps ServiceDeps) http.Handler
|
||||||
|
SettingsPanel func() string
|
||||||
|
AdminPages func() []AdminPage
|
||||||
|
|
||||||
|
CSSManifest func() *CSSManifest
|
||||||
|
ServiceHandlers func(deps ServiceDeps) (*ServiceRegistration, error)
|
||||||
|
JobHandlers func(deps ServiceDeps) map[string]JobHandlerFunc
|
||||||
|
AIActions func() []AIAction
|
||||||
|
|
||||||
|
DirectoryExtensions func() *DirectoryExtensions
|
||||||
|
MediaHooks MediaHooksProvider
|
||||||
|
|
||||||
|
Dependencies []Dependency
|
||||||
|
Migrations func() fs.FS
|
||||||
|
Version string
|
||||||
|
}
|
||||||
61
plugin/service.go
Normal file
61
plugin/service.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
"git.dev.alexdunmow.com/ninja/core/rbac"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceMount is a path + handler pair for an RPC service.
|
||||||
|
type ServiceMount struct {
|
||||||
|
Path string
|
||||||
|
Handler http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectServiceBinding describes a plugin RPC service mounted with the core interceptor chain.
|
||||||
|
type ConnectServiceBinding struct {
|
||||||
|
name string
|
||||||
|
build func(opts ...connect.HandlerOption) (string, http.Handler)
|
||||||
|
methodRoles map[string]rbac.Role
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConnectServiceBinding creates a service binding whose handler is always
|
||||||
|
// constructed by core with the supplied Connect handler options.
|
||||||
|
func NewConnectServiceBinding[T any](
|
||||||
|
name string,
|
||||||
|
svc T,
|
||||||
|
constructor func(T, ...connect.HandlerOption) (string, http.Handler),
|
||||||
|
methodRoles map[string]rbac.Role,
|
||||||
|
) ConnectServiceBinding {
|
||||||
|
clonedRoles := make(map[string]rbac.Role, len(methodRoles))
|
||||||
|
maps.Copy(clonedRoles, methodRoles)
|
||||||
|
|
||||||
|
return ConnectServiceBinding{
|
||||||
|
name: name,
|
||||||
|
build: func(opts ...connect.HandlerOption) (string, http.Handler) {
|
||||||
|
return constructor(svc, opts...)
|
||||||
|
},
|
||||||
|
methodRoles: clonedRoles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b ConnectServiceBinding) Name() string {
|
||||||
|
return b.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b ConnectServiceBinding) Build(opts ...connect.HandlerOption) (string, http.Handler) {
|
||||||
|
return b.build(opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b ConnectServiceBinding) MethodRoles() map[string]rbac.Role {
|
||||||
|
cloned := make(map[string]rbac.Role, len(b.methodRoles))
|
||||||
|
maps.Copy(cloned, b.methodRoles)
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceRegistration bundles plugin RPC services with their RBAC method roles.
|
||||||
|
type ServiceRegistration struct {
|
||||||
|
Services []ConnectServiceBinding
|
||||||
|
}
|
||||||
64
plugin/topo_sort.go
Normal file
64
plugin/topo_sort.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// TopologicalSort orders plugins so that dependencies come before dependents.
|
||||||
|
// Returns error on circular dependencies or missing required dependencies.
|
||||||
|
func TopologicalSort(plugins []PluginRegistration) ([]PluginRegistration, error) {
|
||||||
|
byName := make(map[string]*PluginRegistration, len(plugins))
|
||||||
|
for i := range plugins {
|
||||||
|
byName[plugins[i].Name] = &plugins[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range plugins {
|
||||||
|
for _, dep := range p.Dependencies {
|
||||||
|
if dep.Required {
|
||||||
|
if _, ok := byName[dep.Plugin]; !ok {
|
||||||
|
return nil, fmt.Errorf("plugin %q requires %q, but it is not available", p.Name, dep.Plugin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inDegree := make(map[string]int, len(plugins))
|
||||||
|
dependents := make(map[string][]string)
|
||||||
|
|
||||||
|
for _, p := range plugins {
|
||||||
|
if _, ok := inDegree[p.Name]; !ok {
|
||||||
|
inDegree[p.Name] = 0
|
||||||
|
}
|
||||||
|
for _, dep := range p.Dependencies {
|
||||||
|
if _, ok := byName[dep.Plugin]; ok {
|
||||||
|
inDegree[p.Name]++
|
||||||
|
dependents[dep.Plugin] = append(dependents[dep.Plugin], p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var queue []string
|
||||||
|
for _, p := range plugins {
|
||||||
|
if inDegree[p.Name] == 0 {
|
||||||
|
queue = append(queue, p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []PluginRegistration
|
||||||
|
for len(queue) > 0 {
|
||||||
|
name := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
result = append(result, *byName[name])
|
||||||
|
|
||||||
|
for _, dep := range dependents[name] {
|
||||||
|
inDegree[dep]--
|
||||||
|
if inDegree[dep] == 0 {
|
||||||
|
queue = append(queue, dep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) != len(plugins) {
|
||||||
|
return nil, fmt.Errorf("circular dependency detected among plugins")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
121
plugin/types.go
Normal file
121
plugin/types.go
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pool interface for database transactions (satisfied by *pgxpool.Pool).
|
||||||
|
type Pool interface {
|
||||||
|
Begin(ctx context.Context) (pgx.Tx, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobHandlerFunc is the signature for background job handlers.
|
||||||
|
type JobHandlerFunc func(ctx context.Context, config json.RawMessage, progress func(current, total int, message string)) (json.RawMessage, error)
|
||||||
|
|
||||||
|
// Dependency declares that a plugin requires another plugin.
|
||||||
|
type Dependency struct {
|
||||||
|
Plugin string
|
||||||
|
MinVersion string
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailSender is the narrow plugin-facing interface for sending emails.
|
||||||
|
type EmailSender interface {
|
||||||
|
Send(to, subject, body string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPage defines a page that a plugin registers in the admin sidebar.
|
||||||
|
type AdminPage struct {
|
||||||
|
Key string
|
||||||
|
Title string
|
||||||
|
Icon string
|
||||||
|
Route string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIAction declares an AI task registered in the AI Action Matrix.
|
||||||
|
type AIAction struct {
|
||||||
|
Key string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
DefaultProvider string
|
||||||
|
DefaultModel string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MasterPageBlock defines a block to add to a master page during provisioning.
|
||||||
|
type MasterPageBlock struct {
|
||||||
|
BlockKey string
|
||||||
|
Title string
|
||||||
|
Content map[string]any
|
||||||
|
HtmlContent string
|
||||||
|
Slot string
|
||||||
|
SortOrder int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// MasterPageDefinition defines a default master page that a plugin provisions.
|
||||||
|
type MasterPageDefinition struct {
|
||||||
|
Key string
|
||||||
|
Title string
|
||||||
|
PageTemplates []string
|
||||||
|
Blocks []MasterPageBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin status constants.
|
||||||
|
const (
|
||||||
|
StatusLoaded = "loaded"
|
||||||
|
StatusFailed = "failed"
|
||||||
|
StatusDisabled = "disabled"
|
||||||
|
StatusRebuilding = "rebuilding"
|
||||||
|
StatusPendingRestart = "pending_restart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plugin source constants.
|
||||||
|
const (
|
||||||
|
SourceBundled = "bundled"
|
||||||
|
SourceInstalled = "installed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaAnalyzedEvent is emitted after media scanning completes.
|
||||||
|
type MediaAnalyzedEvent struct {
|
||||||
|
MediaID uuid.UUID
|
||||||
|
AnalysisID uuid.UUID
|
||||||
|
ContentHash string
|
||||||
|
Status string
|
||||||
|
SourcePlugin string
|
||||||
|
SourceType string
|
||||||
|
SourceRefID uuid.UUID
|
||||||
|
SafeAdult string
|
||||||
|
SafeViolence string
|
||||||
|
SafeRacy string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationDecisionEvent is emitted when a moderation decision is made.
|
||||||
|
type ModerationDecisionEvent struct {
|
||||||
|
MediaID uuid.UUID
|
||||||
|
AnalysisID uuid.UUID
|
||||||
|
Status string
|
||||||
|
PreviousStatus string
|
||||||
|
SourcePlugin string
|
||||||
|
SourceType string
|
||||||
|
SourceRefID uuid.UUID
|
||||||
|
ModeratedBy uuid.UUID
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaHooksProvider is the interface plugins implement to receive media lifecycle events.
|
||||||
|
type MediaHooksProvider interface {
|
||||||
|
OnMediaAnalyzed(ctx context.Context, event MediaAnalyzedEvent) error
|
||||||
|
OnModerationDecision(ctx context.Context, event ModerationDecisionEvent) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectoryExtensions allows plugins to customize the directory handler UI.
|
||||||
|
type DirectoryExtensions struct {
|
||||||
|
PanelSections []func(data map[string]any) string
|
||||||
|
PinDecorators []func(pin map[string]any, data map[string]any)
|
||||||
|
BooleanFilterFields []string
|
||||||
|
SelectFilterFields []string
|
||||||
|
BadgeLabels map[string][2]string
|
||||||
|
}
|
||||||
46
plugin/version.go
Normal file
46
plugin/version.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/mod/semver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseModVersion extracts the version string from an embedded plugin.mod file.
|
||||||
|
// Returns "0.0.0" if parsing fails or version is not found.
|
||||||
|
func ParseModVersion(data []byte) string {
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if strings.HasPrefix(line, "version") {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val := strings.TrimSpace(parts[1])
|
||||||
|
val = strings.Trim(val, `"`)
|
||||||
|
if val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareVersions compares two semver strings.
|
||||||
|
// Returns -1 if v1 < v2, 0 if equal, +1 if v1 > v2.
|
||||||
|
// Returns 0 if either version is invalid.
|
||||||
|
func CompareVersions(v1, v2 string) int {
|
||||||
|
if !strings.HasPrefix(v1, "v") {
|
||||||
|
v1 = "v" + v1
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(v2, "v") {
|
||||||
|
v2 = "v" + v2
|
||||||
|
}
|
||||||
|
if !semver.IsValid(v1) || !semver.IsValid(v2) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return semver.Compare(v1, v2)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user