Files

152 lines
6.0 KiB
Go
Raw Permalink Normal View History

package autoscan
2026-07-29 17:49:46 +00:00
import (
"encoding/json"
"strings"
)
// AdminForm describes per-source configuration fields for a scan source, in the
// same shape the admin UI's generic schema renderer already consumes for plugin
// config (see web/src/components/admin/plugins/SchemaForm.tsx). Reusing that
// shape is deliberate: a scan source's config form is the same kind of thing as
// a plugin's config form, and the renderer for it already exists.
//
// The host never interprets these fields. It carries them from the capability
// manifest to the admin UI, and stores whatever values come back in the
// source's SourceConfig map.
type AdminForm struct {
Fields []AdminFormField `json:"fields"`
SubmitLabel string `json:"submit_label,omitempty"`
Sections []AdminFormSection `json:"sections,omitempty"`
}
// AdminFormField is one control. Control values match the SDK's
// AdminFormControl enum names as rendered by the admin UI ("TEXT", "TEXTAREA",
// "PASSWORD", "NUMBER", "SWITCH", "SELECT", "MULTI_SELECT"); the host passes
// them through without validating, so a newer control name from a newer plugin
// degrades in the UI rather than being rejected here.
type AdminFormField struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Control string `json:"control"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
Multiline bool `json:"multiline,omitempty"`
DefaultValue any `json:"default_value,omitempty"`
Options []AdminFormOption `json:"options,omitempty"`
Rows int `json:"rows,omitempty"`
DynamicOptions bool `json:"dynamic_options,omitempty"`
ShowWhen []AdminFormCondition `json:"show_when,omitempty"`
Validation *AdminFormValidation `json:"validation,omitempty"`
// FillFrom names a host-known value the admin UI can offer to populate this
// field from, as a one-click action beside it. It exists so a path-shaped
// field can be filled from Silo's own library paths without the UI needing
// to know which plugin it belongs to. Unknown values are ignored by the UI.
FillFrom string `json:"fill_from,omitempty"`
}
// Control names the admin UI renders. These mirror the SDK's AdminFormControl
// enum; the host only names the ones it builds forms for itself.
const (
ControlText = "TEXT"
ControlTextarea = "TEXTAREA"
ControlSelect = "SELECT"
2026-07-29 17:49:46 +00:00
ControlPassword = "PASSWORD"
)
// Fill sources the admin UI understands for AdminFormField.FillFrom.
const (
// FillFromMovieLibraryPaths offers the paths of every enabled movie
// library; FillFromTVLibraryPaths the same for series libraries. Mixed
// libraries contribute to both.
FillFromMovieLibraryPaths = "library_paths_movie"
FillFromTVLibraryPaths = "library_paths_tv"
)
type AdminFormOption struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
}
type AdminFormCondition struct {
Field string `json:"field"`
Equals []string `json:"equals"`
}
type AdminFormValidation struct {
HasMin bool `json:"has_min,omitempty"`
Min float64 `json:"min,omitempty"`
HasMax bool `json:"has_max,omitempty"`
Max float64 `json:"max,omitempty"`
Pattern string `json:"pattern,omitempty"`
MinLength int `json:"min_length,omitempty"`
MaxLength int `json:"max_length,omitempty"`
}
type AdminFormSection struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Collapsible bool `json:"collapsible,omitempty"`
CollapsedDefault bool `json:"collapsed_default,omitempty"`
FieldKeys []string `json:"field_keys"`
ShowWhen []AdminFormCondition `json:"show_when,omitempty"`
}
// adminFormFromMetadata decodes an admin form out of a decoded-JSON metadata
// value. It round-trips through encoding/json rather than walking the map by
// hand, so the field set stays in sync with the struct tags above.
//
// A malformed form yields nil rather than an error: a plugin with a broken
// config form must still be discoverable and creatable, just without its
// bespoke fields.
func adminFormFromMetadata(value any) *AdminForm {
if value == nil {
return nil
}
raw, err := json.Marshal(value)
if err != nil {
return nil
}
var form AdminForm
if err := json.Unmarshal(raw, &form); err != nil {
return nil
}
form.Fields = withoutUnsupportedFields(form.Fields)
if len(form.Fields) == 0 {
return nil
}
return &form
}
2026-07-29 17:49:46 +00:00
// withoutUnsupportedFields drops fields the source-config surface cannot honour.
2026-07-29 17:49:46 +00:00
//
// Secret fields: a source's values land in autoscan_sources.source_config,
// which is plain JSONB and is returned verbatim by the source API — unlike
// connection API keys, which go through the repository's encrypted path.
// Rendering a masked input over a value stored in the clear would misrepresent
// how it is held, so the host declines to collect it at all. Plugins needing a
// credential should take a connection instead.
//
// Dynamic-option fields: the shared renderer populates those from a
// connection-aware probe that only the plugin-config page performs. On a source
// form they would render as an empty select, and a required one could never be
// satisfied — permanently blocking creation. Dropping them fails visibly at the
// contract rather than invisibly at the operator.
func withoutUnsupportedFields(fields []AdminFormField) []AdminFormField {
2026-07-29 17:49:46 +00:00
kept := make([]AdminFormField, 0, len(fields))
for _, field := range fields {
if field.Secret || strings.EqualFold(field.Control, ControlPassword) {
continue
}
if field.DynamicOptions {
continue
}
2026-07-29 17:49:46 +00:00
kept = append(kept, field)
}
return kept
}