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
|
||||
|
||||
require (
|
||||
connectrpc.com/connect v1.19.2
|
||||
github.com/a-h/templ v0.3.1001
|
||||
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/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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/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