From 7c20538a4e303f4bcf880369b1bb9fc21fc2c363 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Thu, 30 Apr 2026 22:41:52 +0800 Subject: [PATCH] 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 --- go.mod | 10 ++++ go.sum | 32 +++++++++++ plugin/block_registry.go | 32 +++++++++++ plugin/css_manifest.go | 44 ++++++++++++++ plugin/deps.go | 65 +++++++++++++++++++++ plugin/doc.go | 1 - plugin/provisioner.go | 107 ++++++++++++++++++++++++++++++++++ plugin/registration.go | 44 ++++++++++++++ plugin/service.go | 61 ++++++++++++++++++++ plugin/topo_sort.go | 64 +++++++++++++++++++++ plugin/types.go | 121 +++++++++++++++++++++++++++++++++++++++ plugin/version.go | 46 +++++++++++++++ 12 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 plugin/block_registry.go create mode 100644 plugin/css_manifest.go create mode 100644 plugin/deps.go delete mode 100644 plugin/doc.go create mode 100644 plugin/provisioner.go create mode 100644 plugin/registration.go create mode 100644 plugin/service.go create mode 100644 plugin/topo_sort.go create mode 100644 plugin/types.go create mode 100644 plugin/version.go diff --git a/go.mod b/go.mod index eb09f20..4c53053 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 5228531..969a6da 100644 --- a/go.sum +++ b/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= diff --git a/plugin/block_registry.go b/plugin/block_registry.go new file mode 100644 index 0000000..5529c44 --- /dev/null +++ b/plugin/block_registry.go @@ -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) +} diff --git a/plugin/css_manifest.go b/plugin/css_manifest.go new file mode 100644 index 0000000..9da1495 --- /dev/null +++ b/plugin/css_manifest.go @@ -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 +} diff --git a/plugin/deps.go b/plugin/deps.go new file mode 100644 index 0000000..7199f5b --- /dev/null +++ b/plugin/deps.go @@ -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 +} diff --git a/plugin/doc.go b/plugin/doc.go deleted file mode 100644 index b0736c3..0000000 --- a/plugin/doc.go +++ /dev/null @@ -1 +0,0 @@ -package plugin diff --git a/plugin/provisioner.go b/plugin/provisioner.go new file mode 100644 index 0000000..b9d73d9 --- /dev/null +++ b/plugin/provisioner.go @@ -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 +} diff --git a/plugin/registration.go b/plugin/registration.go new file mode 100644 index 0000000..38aa5bd --- /dev/null +++ b/plugin/registration.go @@ -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 +} diff --git a/plugin/service.go b/plugin/service.go new file mode 100644 index 0000000..6f30c0d --- /dev/null +++ b/plugin/service.go @@ -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 +} diff --git a/plugin/topo_sort.go b/plugin/topo_sort.go new file mode 100644 index 0000000..a50e851 --- /dev/null +++ b/plugin/topo_sort.go @@ -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 +} diff --git a/plugin/types.go b/plugin/types.go new file mode 100644 index 0000000..03aebc1 --- /dev/null +++ b/plugin/types.go @@ -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 +} diff --git a/plugin/version.go b/plugin/version.go new file mode 100644 index 0000000..0b570cf --- /dev/null +++ b/plugin/version.go @@ -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) +}