Skip to content

Render-ready UI definitions for configurations and actions, plus a Roslyn analyzer for unrenderable shapes - #1414

Open
revam wants to merge 50 commits into
masterfrom
feat/gen-ui-dto
Open

revam wants to merge 50 commits into
masterfrom
feat/gen-ui-dto

Conversation

@revam

@revam revam commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

No longer a draft.

Breaking for the WebUI, and going out on master. x-uiDefinition is cut down
to the five entries the server's validator reads back, so the configuration form
and the renamer script editor need to move to UiDefinition. That now includes
TMDB, whose settings left ServerSettings for a plugin configuration of their own
(tmdb.json, TmdbConfiguration), and the WebAOM renamer, now a bundled plugin.
Both are served through the same UiDefinition route as any other configuration.
The two sides do not have to land together — the new shape is servable from this
branch, so the client work can be built against it while this is still open.
Details under What this changes.

The problem

A client that wants to render a configuration form today has to read two documents
and reconcile them: the JSON Schema for structure, and the x-uiDefinition
extension bag for presentation. JSON Schema is a validation vocabulary, so
presentation has to be inferred from it — elementType: "auto" is literally an
instruction to go and read schema.type. References are unresolved, so the client
dereferences $ref itself while carefully preserving the UI metadata a
standards-compliant dereferencer would discard.

Executable actions have it worse. ActionService can deserialise a parameter
payload straight onto an action instance, and twelve shipped actions declare
parameters — but nothing describes them and the API exposes no way to send them,
so an action with parameters can only be invoked in-process, on its defaults.

What this adds

Shoko.Abstractions.UI.UiDefinition — a description of a form, shaped for
rendering. An abstract UiElement with a concrete Kind discriminator and 15
sealed element types; $refs resolved; labels pre-resolved; constraints,
nullability and requiredness on the element that needs them. auto never appears
on the wire.

A container files its members in three maps, Items, Actions and
FloatingSections, and puts them back in order with Structure, whose entries
carry a Kind of item, action or floating-section naming the map to look in.
So resolving an entry is an index rather than a scan, and grouping is decided here
rather than derived on every render.

Members keep the order they were authored in. A nested class stays an item and
labels itself, [SectionName] gathers members into a floating section entered at
the first of them, and anything unnamed stays where it is. A container laid out as
tabs, or one that named its default section, gathers what is left over, because a
tab needs a label. AppendFloatingSectionsAtEnd moves the gathered sections past
the rest. [FloatingSection("Login", Description = "…")] on the class describes a
gathered section, which has no type of its own to carry one.

Buttons follow the same order, with one exception that hoists out of it: a button
pinned with Position lands in StartActions or EndActions on whatever holds
it. A client never filters by position, and Position itself does not reach the
wire.

GET /api/v3/Configuration/{configID}/UiDefinition a configuration's form
GET /api/v3/Action/{actionID}/UiDefinition an action's parameter form, any scope
POST /api/v3/Action/{actionID} (and the four scoped variants) now accepts an optional parameter body

Action parameters are generated from the action's own settable properties — the
same ones the populator writes to — with the metadata surface (Name, Category,
Permission, …) excluded, and that exclusion is enforced by the contract resolver
rather than by validation alone, so a payload cannot reach those members even if
validation is bypassed. Both configurations and actions go through the same
builders and emit the identical shape, so a renderer needs no special case. The
definition reaches a plugin as ExecutableActionInfo.ParameterDefinition, null
for an action that takes none.

An incoming parameter body is validated against the action's parameter schema
before anything is populated, and the objects in that schema are closed — the
populator ignores members it cannot map, so without this a mistyped parameter name
would silently do nothing and the action would run on its defaults. Only a parameter
the action marks [Required] or required must be sent; any other omitted
parameter keeps the instance's C# default, and sending one parameter does not
oblige you to send all of them. The check is
IActionService.ValidateParameters(actionId, JObject?), which answers errors by
property path, the shape the configuration endpoints already return for a rejected
body. It sits on the interface rather than the concrete service because a plugin
serving its own routes takes bodies too.

IConfigurationService.GenerateUiDefinition(Type) — mirrors the existing
GenerateSchema(Type), with no IConfiguration constraint, so a plugin can
describe any type it owns. ConfigurationInfo.UiDefinition is a lazy cache over it.

Shoko.BuildTools.Analyzers — a Roslyn analyzer, packed under
analyzers/dotnet/cs/, turning configuration and action shapes the generator
cannot render into compiler errors. Sixteen rules, SHOKO0001 to SHOKO0016,
covering every throw in ShokoJsonSchemaGenerator plus one silent metadata loss
that never threw, each explained with a bad example and its fix in
Shoko.BuildTools.Analyzers/README.md. It walks the property graph from IConfiguration and
IExecutableAction roots, so an identical shape elsewhere in the codebase is
untouched. The runtime checks remain — a plugin can be built without referencing
the package.

What this changes

The schema stays the validation contract, but x-uiDefinition shrinks to what
the validator reads.
The bag existed to carry presentation; UiDefinition does
that now. What remains is the five entries ShokoJsonSchemaValidator reads back
during validation — elementType, requiresRestart, envVar,
envVarOverridable and enumDefinitions (itself down to value and
aliasValues) — and a property carrying none of them gets no bag at all. The
class-level bag is gone entirely; nothing ever read it. Env-var splicing, env-lock
enforcement, enum alias resolution and restart-pending detection are unchanged.
ServerSettings' schema is 2579 lines pretty-printed (sizes under Measurements), and a golden-file test
pins the result for every shipped configuration.

The generator's internals are typed. Dictionary<string, object?>
accumulators become typed builders, and a joiner walks the finished schema
alongside them — schema facts from the schema node, presentation from the builder.
String constants drop from 52 to 9: the bag's key, the five entries inside it,
the two keys of an enum definition, and the separator its aliases are joined with.

DisplayButtonPosition loses its aliases. Top/Left shared a value with
Start, and Bottom/Right with End, while [EnumMember] sat only on the
canonical members — so which name the runtime resolved for a duplicate was
unspecified, and every alias lost its attribute. Position = Top was serialising
as "Left". Now Auto/Start/End only, and the value stays on the attribute:
the definition says where a button goes by which list it appears in. Source-breaking
on a pre-release package, though nothing reads the wire value: the WebUI on
master models x-uiDefinition without an actions member at all.

The shared authoring surface moves to Shoko.Abstractions.UI — presentational
attributes, the Display* enums, SelectComponent<>. Config.Enums was already
being imported by the UI namespace, so the dependency pointed the wrong way.
Configuration-lifecycle attributes (StorageLocation, ConfigurationAction,
HideDefaultSaveAction, RequiresRestart, EnvironmentVariable) stay in
Config. Source-breaking.

CodeEditorAttribute.Height is gone. Nothing read it, and an element's size
is [Visibility(Size = …)] like any other. CodeEditorLanguage serialises in
kebab-case like its sibling enums (plain-text, csharp, json).

A select's value keeps its members' declared names. SelectComponent<>,
SelectOption<> and SelectGroup no longer pin lowercase JSON names (options,
selected, groupId, …), so their value is written like every other property in
the document. Configurations saved with the old names still load, since both
serialisers read property names case-insensitively, and are written with the new
ones on the next save. No configuration in the server uses a select.

Added in review

Everything below came out of the first round of review, and is in the same branch.

Conditions compare with an operator. UiCondition was one path, one value and
one equality, with an InverseCondition flag standing in for every comparison it
could not make. UiConditionOperator adds NotEquals, IsEmpty, IsNotEmpty,
In, NotIn, GreaterThan, LessThan and Contains, with Equals as the
default, and the inverse flag is gone from both attributes and the wire. A cleared
text field holds "", which IsEmpty catches and an equality test against null
never did. A condition path may descend into a nested class, spelled
$"{nameof(Nested)}.{nameof(NestedConfiguration.Mode)}", and may not point through
a list or dictionary, which carries no index.

A live-edit handler can say what it reacts to. ConfigurationInfo.HasLiveEdit
was one bool for a whole configuration, so a client either posted the entire
document on every event on every field or never called LiveEdit at all. A
container now carries HasLiveEdit and HasNestedLiveEdit, each floating section
carries HasNestedLiveEdit, and the hook itself narrows what it wants:

[ConfigurationAction(ConfigurationActionType.LiveEdit,
    Events = [ReactiveEventType.Unfocused, ReactiveEventType.Edited],
    ReactiveMembers = [nameof(FfmpegPath), $"{nameof(Nested)}.{nameof(NestedConfiguration.Mode)}"])]

which reaches the element as ReactsToLiveEdit, the events an edit to it is worth
sending on. Events replaces the singular ReactiveEventType: a request carries
one event, a handler is interested in as many as it likes. Leave both off and the
handler keeps its old reach, being its own class and everything below it without a
handler of its own.

Every live-edit handler the edited path passes through runs. Only the one
nearest the edit used to, so a class coordinating its nested types never heard
about an edit to one of them. They now run innermost first, each handed what the
one before it returned on ConfigurationActionContext.PreviousResult and as an
injectable parameter, and their results are combined: messages in run order,
validation errors merged by key, the first redirect, the flags or'd. They share the
configuration instance, so the document needs no merging. A path that does not
resolve, which is what a row still being composed looks like, is a no-op for a live
edit, while an invoked action still fails.

A row can say what it is called. A class holding a TitleComponent labels its
rows with it: a list or a record points ItemTitlePath and ItemCategoryPath at
its Title and SubTitle, rather than the client guessing from member names.
Failing that the [Key] member is used, and a record entry falls back to its key.
An unauthored layout is settled before it is served, Flat being the new name for
a plain row, so auto never reaches a client here either.

Two more analyzer rules. SHOKO0006 for a condition that could never hold, and
SHOKO0007 for a handler watching a member that is not there, a name pointing
through a collection, or a hook no event raises trying to narrow itself. Both are
checked again at startup, for a plugin built without the package.

UiAction.ID is Name. A configuration action is identified by the name of
the method behind it and goes back as actionName, while an executable action is
identified by a Guid. Both sat under ID.

Descriptions are unwrapped and trimmed. A summary is indented and line-wrapped
for whoever reads the source, and both reached the client verbatim. ServerSettings
alone has about a hundred descriptions carrying a stray newline or an untrimmed end.

Added in the second round

Options the server lists. [OptionsProvider(nameof(A), nameof(B))] on a method
turns the members it names into choices the server enumerates on request.

[OptionsProvider(nameof(LibraryId), nameof(ExtraLibraryIds))]
public async Task<IReadOnlyList<SelectOption<int>>> ListLibraries(ILibraryService libraries)
    => (await libraries.GetAll()).Select(x => new SelectOption<int>(x.ID, x.Name)).ToList();

[OptionsProvider(nameof(ModeByPlugin), Target = OptionsTarget.Keys)]
public IEnumerable<SelectOption<Guid>> ListPlugins() => …;
  • The method returns T[] or any IEnumerable<T>, or SelectOption<T> of it to
    label each option, directly or through Task/ValueTask. Its parameters are
    filled in the way a custom action's are: the draft with its unsaved edits, the
    user and any service. On an executable action it runs on an instance prepared the
    way invoking prepares one, scope entity and caller included.
  • Target picks the part: Values (the default) is a scalar, a list's entries or a
    dictionary's values; Keys is a dictionary's keys. A dictionary's keys and values
    may have different providers, and one provider may serve several members of the
    same option type.
  • T is a primitive, an enum, one of the BCL types that round-trip through text, or
    any type, a plugin's own included, implementing IParsable<TSelf> or carrying a
    [TypeConverter] to and from a string or primitive. The provider's T must match
    the member's option type exactly; conversions never bridge them.
  • The element gains HasOptions. A client POSTs to its form's own route plus
    /Options: the configuration's /api/v3/Configuration/{configID}, or the route
    it invokes an executable action on (not a configuration's custom action, and any
    scope, whose entity the server sets on the action). The body is the draft or the
    parameters entered so far, and path names the field getting the options:
    Library, Tags for a list's entries, Mappings[2].Folder for a field in a
    list entry, ModeByPlugin for a dictionary's keys, and ModeByPlugin["<key>"]
    for the values of one key, which need not be saved yet. A values provider may
    take that key with [OptionsKey] to narrow its values per key, so a client
    picks a new entry's key first.
  • The answer is a list of { Value, Label } in the order returned. Label is always
    set: the provider's, else the enum member's name as the form shows it, else the
    value as text. Nulls are skipped, duplicates kept, and an empty list means nothing
    to choose right now. Clients should show stored values by the label of a listed
    option (a stored Guid key by its plugin's name, say) and fall back to the raw
    value. There is no server-side search: providers return the full current list.
  • A provider may refuse with GenericValidationException, answered as a validation
    problem keyed as thrown; anything else is a server error.
  • Only methods carrying the attribute are looked at, so a model may have methods of
    any shape. A provider that does not fit fails startup, and SHOKO0008 to
    SHOKO0013 and SHOKO0015 (an [OptionsKey] parameter that does not fit)
    report the same at compile time, one rule per kind of mistake.
  • In the core, PluginSettings (Plugins.EnabledPlugins keys and Plugins.Priority
    entries) lists the installed plugins by DLL name, labelled with their display
    names. Both settings are advanced-only, so the default form looks the same.

Actions attached to a member's row are gone. [CustomAction(AttachToMember = …)],
AttachedStartActions and AttachedEndActions were removed. Configuration-level
custom actions are unchanged.

A plugin type that parses from text is text. A type implementing
IParsable<TSelf> without a [TypeConverter] was described and written as an
object; it is now a string element, written with its invariant ToString() and
read with TryParse in configurations and action parameters, so no converter is
needed. A type wanting another wire shape declares [JsonConverter] or
[TypeConverter].

SelectOption<T> no longer requires IEquatable<T>, so enums can carry labels.

A flag enum is a list of its members. A [Flags] member is described and
rendered exactly like a List<TEnum>, so a client treats it as enum[] and needs
no new element. A configuration or action parameter writes it as an array of its
single-bit member names ([] for none); the old comma-separated string and the
integer are still accepted on read for backwards compatibility, but the schema
only describes the array. Combined members such as All = A | B stay on the
server: they are expanded on read and never written or offered. An options
provider narrows its entries as it does for any list. This covers the
configuration and action routes only. The APIv3 models keep their shapes, so
ManagedFolder.DropFolderType and /api/v3/Settings are unchanged, which leaves
AniDb.MyList.FetchMode and SyncTargets as arrays in the configuration routes
and numbers in the settings route. ServerSettings holds those two, so settings- server.json now stores them as arrays; older files still load.

Test plugin. revam/dotnet-shoko-plugin-genui-test
exercises every feature above for manual testing, with one configuration per level
of reactivity (save-only, conditions, custom actions, server options, live edit,
lifecycle hooks, combined) and two executable actions. It is never released and has
no manifest; build it against a checkout of this branch with
-p:ShokoRepositoryPath=….

Found by the WebUI test harness. Driving the test plugin through the harness
turned up a few more contract gaps, now closed:

  • ReactiveEventType is written by its kebab-case name (ReactsToLiveEdit was
    numbers), and LiveEdit binds those names (view-changed, new-value).
  • Saving a configuration always runs the Validate hook first, also behind a
    custom Save hook.
  • Defaults in the schema and the definition come from a constructed instance, so
    property initialisers count, not only [DefaultValue]. An executable action's
    parameter defaults ride in its definition.
  • An action's options request reads the draft leniently, so an unrelated invalid
    parameter no longer blocks it.
  • A bad options path answers a validation problem keyed by that path.
  • A checkbox section names its switch: [Section(DisplaySectionType.Checkbox, ToggleMember = nameof(Enabled))], exposed as ToggleMember and checked by
    SHOKO0016.

Defects this closes

  • Inherited configuration properties emit no UI metadata at all. Property
    metadata is filed under the reflected type and class metadata under the
    generated type, so a configuration inheriting from a base loses labels,
    visibility, sections — everything — for every inherited member. No shipped
    configuration inherits, so it is dormant here and immediate for a plugin author.
  • A collection inside a collection fails three different ways.
    List<List<T>> and Dictionary<K,Dictionary<K,V>> collide on a single property
    key and the outer level is silently dropped; List<Dictionary<K,V>> throws with
    a message naming the wrong type. All are now rejected with an actionable error,
    at compile time and at runtime. A dictionary whose values are collections is
    fine and stays supported.
  • Enum-keyed dictionaries describe the wrong thing. Key and value share a
    property key, so the value element came back as an enum carrying the key's
    members, and the key itself rendered as free text.
  • Every lifecycle hook ran the live-edit handler, or nothing. Each handler
    lookup asked for a LiveEdit handler whatever hook was being run, so a class
    declaring Load, New, Save or Validate had its live-edit handler run in
    its place, or resolved nothing at all. GET /api/v3/Configuration/{configID}
    answered 409 for a Load handler, and a custom Save handler silently
    persisted nothing. Present on master as well; nothing published hits it.
  • An action parameter that passed the schema but failed to convert answered
    500.
    A flag-enum name or a parsable value that did not parse threw while the
    parameters were populated; every invoke and options route now answers a
    validation problem keyed by the parameter.
  • Any public method of a configuration class could be invoked as a custom
    action.
    PerformAction looked the method up by name alone. Found here and fixed
    on master separately.

Measurements

ServerSettings, minified, measured on the rebased branch against current
master (a33696c1b). On master the schema carries the presentation bag and
comes to 106,341 B. On this branch the schema is 57,531 B, a 46% drop, and the
definition is 127,390 B with nulls omitted, or 163,361 B as MVC emits it with
nulls included.

A renderer used to pull the 106,341 B schema-plus-bag and now pulls the 127,390 B
definition instead, so transfer is up about 20%, down from 29% in the first
round, and the client stops reconciling two documents to get there. The second
round removed the member-attached action lists. The figures predate the options
providers on Plugins.EnabledPlugins and Plugins.Priority, the only elements
setting HasOptions.

Against the current renderer (src/components/Dynamic/, 275 lines plus a 75-line
$ref helper): the helper becomes unnecessary in full, and DynamicField's auto
branch, roughly 45 of its 134 lines, hand-dispatching on propertySchema.type and
reading minimum/maximum off the schema node, collapses to a switch on Kind.

Settled in review

  • A server-enumerated set of choices is the options provider, under
    Added in the second round.
  • Flag enums are a list of their members, under the same heading.
  • An optional monotonic revision on the LiveEdit response is not added. A
    live edit persists nothing and only the client issuing it can change that draft,
    so the client orders its own in-flight requests. It can be added later, without
    breaking anything, if something else ever changes a draft mid-edit.

@harshithmohan

harshithmohan commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

What I would like feedback on

The DTO shape itself. Nothing consumes it yet, so this is the free moment to change it. If any element type is awkward to render, say so now.

Can't give feedback when the DTO shape is not displayed anywhere. For other reviewers' reference, here's the current shape (553db752) — the AniDB login area of CoreSettings, from a live payload of GET /api/v3/Configuration/{configID}/UiDefinition (trimmed):

{
  "ID": "19c7a5e2-…",
  "Name": "Core Settings",
  "Root": {
    "Kind": "section-container",          // concrete discriminator — no more "auto"
    "SectionType": "tab",
    "DefaultSectionName": "Misc.",
    "AppendFloatingSectionsAtEnd": true,
    "ShowSaveAction": true,
    "Items": {                            // map keyed by document property name
      "Username": {
        "Kind": "string",
        "Key": "Username",
        "Label": "Username",
        "Description": "AniDB username.",
        "Size": "normal",
        "Visibility": { "Default": "visible", "Advanced": false, "Toggle": null, "Disable": null },
        "Badge": null,
        "RequiresRestart": false,
        "EnvironmentVariable": { "Name": "ANIDB_USER", "AllowOverride": false },
        "SectionName": "Login",           // ← grouping lives here today
        "Default": null,
        "IsRequired": false,
        "IsNullable": true,
        "DeniedValues": null
      },
      "Password": { "Kind": "password", "SectionName": "Login", "…": "…" },
      "MaxRelationDepth": {
        "Kind": "integer",
        "Minimum": 0, "Maximum": 5,       // constraints live ON the element now
        "Size": "small",
        "SectionName": "Download",
        "…": "…"
      },
      "MyList_StorageState": {
        "Kind": "enum",
        "Values": [                       // options pre-resolved — no schema round-trip
          { "Title": "Unknown", "Value": "Unknown" },
          { "Title": "HDD",     "Value": "HDD" }, …
        ],
        "SectionName": "MyList", "…": "…"
      }
      // nested objects inline as nested section-containers — no $refs to resolve
    },
    "Actions": {
      "Test": {
        "ID": "Test",
        "Title": "Test",
        "Theme": "primary",
        "Position": "start",
        "Size": "normal",
        "SectionName": "Login",
        "MemberName": "Test",
        "DisableIfNoChanges": false
      }
    },
    "Structure": [                        // authored order, interleaving fields & buttons
      { "Name": "Username", "Kind": "item" },
      { "Name": "Password", "Kind": "item" },
      { "Name": "Test",     "Kind": "action" },
      { "Name": "DownloadCharacters", "Kind": "item" },
      …
    ]
  },
  "Definitions": {}                       // only populated to break reference cycles
}

Each Kind value maps 1:1 to an element-type renderer, and $refs are already resolved or hoisted into Definitions, so consumers don't need schema round-trips.

@harshithmohan

Copy link
Copy Markdown
Member

Proposal: group Structure by section instead of a flat list

The problem

Rendering a tabbed configuration today requires the client to derive grouping on every render:

const fields = Object.values(root.items).filter(e => e.sectionName === activeTab);

Membership in a section is stated twice — once as sectionName on every element and action, and once implicitly through authored order in structure — so the client reconciles the two by filtering all elements each time, for every container depth.

Current shape

{
  "kind": "sectionContainer",
  "sectionType": "tab",
  "items": {
    "Username": { "kind": "string", "sectionName": "Login", "…": "…" },
    "Password": { "kind": "password", "sectionName": "Login", "…": "…" },
    "DownloadCharacters": { "kind": "boolean", "sectionName": "Download", "…": "…" }
  },
  "actions": {
    "Test": { "title": "Test", "sectionName": "Login", "position": "start" }
  },
  "structure": [
    { "name": "Username", "kind": "item" },
    { "name": "Password", "kind": "item" },
    { "name": "Test",     "kind": "action" },
    { "name": "DownloadCharacters", "kind": "item" }
  ]
}

To render one tab: scan every element, filter on sectionName, then use structure only to re-insert buttons in order.

Proposed shape

Keep items/actions as flat maps keyed by property name / action ID (value binding stays O(1)), but make grouping explicit:

{
  "kind": "sectionContainer",
  "sectionType": "tab",
  "items": {
    "Username": { "kind": "string", "…": "…" },          // sectionName gone from elements
    "Password": { "kind": "password", "…": "…" },
    "DownloadCharacters": { "kind": "boolean", "…": "…" }
  },
  "actions": {
    "Test": { "title": "Test", "position": "start" }
  },
  "sections": [
    {
      "name": "Login",
      "members": [
        { "name": "Username", "kind": "item" },
        { "name": "Password", "kind": "item" },
        { "name": "Test",     "kind": "action" }   // interleaving preserved
      ]
    },
    {
      "name": "Download",
      "members": [ { "name": "DownloadCharacters", "kind": "item" } ]
    }
  ]
}

Rendering a tab becomes one lookup plus enumeration:

const members = root.sections.find(s => s.name === activeTab).members;
const nodes = members.map(m => m.kind === "item" ? root.items[m.name] : root.actions[m.name]);

Why this works for non-tab containers too

The shape is identical regardless of sectionType — that stays a pure rendering concern:

  • field-set / minimal / checkbox: enumerate sections, draw each group per type. A container with no [SectionName] anywhere just gets one synthesized section named after defaultSectionName, so something like AVDumpSettings renders as a single unboxed group with zero special-casing.
  • Nested containers: a nested sectionContainer element carries its own sections, so the same render rule composes recursively.

Section ordering would be first-appearance order of authored members, with floating/default sections placed first or last per appendFloatingSectionsAtEnd — the same rule the current flat structure implies, just made explicit per group.

What this buys

  1. Single source of truth for membership — grouping moves from N per-element attributes to one authoritative list; no way for sectionName and structure order to disagree.
  2. No per-render filtering, at any container depth.
  3. Smaller elements — SectionName disappears from both elements and actions.
  4. Interleaving preserved — buttons still sit between fields inside members, which is why structure exists at all.

Payload is roughly a wash: the section name moves off the elements into the section entries rather than being duplicated.

@harshithmohan

harshithmohan commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

Follow-up: renderer-perspective findings on actions, descriptions, and paths

Tested the definition against a live ServerSettings payload and walked the full render/bind/invoke flow. Two concrete requests and two open questions.

1. Actions: add the invocation path to the action, and rename ID → Name

Invoking a config action today requires three inputs:

POST /api/v3/Configuration/{configID}/PerformAction?actionName=Test&path=AniDb
  • configID — available from the definition's own ID ✅
  • actionName — available as the action's ID ("Test") ✅
  • path — not in the definition ❌

The client has to reconstruct path ("dotted keys of ancestor containers from the config root") from its own navigation position. That works only if the renderer diligently tracks the container chain it descended through, and nothing documents that this is what path means. A generic renderer that renders a nested action without tracking ancestry sends path="" and gets a silent failure.

Since the builder knows the ancestor chain at generation time, putting the resolved path directly on the action makes the definition self-sufficient for invocation — consistent with the definition's goal that a client renders (and now invokes) from it alone.

Related nit: config actions use string method names as ID, while standalone executable actions use GUIDs for their IDs. Same field name, two disjoint identifier domains — a generic client keeping one action type can't tell which contract an ID belongs to. Since the config-action value is really a name (it's sent as actionName), renaming ID → Name would remove the overlap.

2. Descriptions leak XML-doc whitespace and source-wrapping newlines

From the live payload:

"Description": "The image source.\n            "
"Description": "Path where the images are stored. If set to `null` then it will use\nthe default location."

Two distinct problems from the same root cause:

  1. Trailing whitespace — untrimmed <summary> blocks leave a newline + indentation on the end.
  2. Mid-sentence newlines — authors wrap <summary> text across lines for source readability, and those hard wraps are emitted verbatim: "…it will use\nthe default location." breaks mid-sentence at whatever column the author happened to wrap at. These newlines carry no meaning and render as broken text in tooltips.

Both need handling at emit time: trim the ends, and collapse/unwrap the interior single newlines (they're wrapping artifacts, not intentional formatting — a deliberate paragraph break can't currently be distinguished from one anyway). Every consumer otherwise re-implements the same cleanup forever; golden files would need regenerating once.

Open question: condition path semantics

UiCondition.Path is specified as "relative to the nearest enclosing object," which leaves three cases undefined:

  1. Inside list items — when an element within a list item carries a condition, does Path resolve per-item-instance (each item evaluates against itself), or against the enclosing configuration object? Per-item seems like the only useful reading, but it should be stated.
  2. Edited vs saved state — conditions presumably evaluate against the client's current unsaved edit state (toggling FileLockChecking instantly disables its dependents), not the last-saved document. Worth confirming.
  3. Upward traversal — paths can only descend into the current container. If cross-container conditions (a nested field conditioning on a root-level value) are meant to be unsupported, fine — but then it's worth stating explicitly so plugin authors don't design around it.

All three produce silently-wrong UI rather than errors if a client guesses differently than the server intends, and all three are exercised by shipped configs today (FileLockWaitTimeMS's disable condition, etc.).

Open question: flag enum serialization

UiEnumElement.IsFlag says multiple values may be combined, but nothing specifies how a combination serializes back into the document. With the current enum converters it's comma-separated names ("Source, Destination"), which is a write-path contract a renderer must get exactly right — no fallback, silent mis-parse on a wrong guess. Nothing shipped uses flags yet, so this is purely about specifying the convention before someone depends on guessing it.

@da3dsoul

da3dsoul commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

Examples are from a hypothetical FfmpegTranscodeConfiguration, the transform config the
plugin-stream-pipeline README names but never writes.

The definition can't say an element is reactive

LiveEdit exists and works. What the definition says about it is
ConfigurationInfo.HasLiveEdit, one bool for the whole configuration.

That leaves a client two options. Post the entire document on every event on every field, or
never call LiveEdit. ServerSettings is ~112 KB, so it's the second, and the mechanism
goes unused.

public ReactiveEventType Reactive { get; set; }   // 0 = not reactive

Nothing can populate it, because the authoring surface has no way to say which members a
handler watches:

[ConfigurationAction(ConfigurationActionType.LiveEdit, ReactiveEventType = ReactiveEventType.Unfocused)]
public ConfigurationActionResult OnEdit(ConfigurationActionContext<FfmpegTranscodeConfiguration> ctx) { … }

That attaches to the type. The members it reacts to are known only to the body of the method.
Something like:

[ReactiveAction(nameof(FfmpegPath), Events = ReactiveEventType.Unfocused | ReactiveEventType.Edited)]

makes it a fact the generator can read.

Two questions the model doesn't currently answer, worth answering deliberately: whether more
than one handler can respond to the same event, and whether a handler on a parent type
observes edits to a nested one.

A third: whether a reactive request is best-effort or strict. A newly added list row exists
client-side before the posted document has it, so a path that doesn't resolve is normal
rather than an error. An explicitly invoked action is the opposite case.

ReactiveEventType conflates two roles

A request says what happened. That's always exactly one thing.

A handler says what it cares about. That's naturally a set — Unfocused or Edited.

Both use the same single-valued enum, so All has to mean "unspecified" in one direction and
"anything" in the other. A handler declaring Unfocused is unreachable from a request that
didn't name an event, since All is the default on both sides and only matches handlers that
also declared All.

Separating them — handler declares a set, request declares one event — is also what makes a
per-element Reactive expressible at all.

A select can't say where its options come from

{ "kind": "select", "key": "VideoEncoder", "label": "Video Encoder",
  "selectType": "auto", "multipleItems": false }

Nothing says this is server-populated, that it derives from FfmpegPath, or that leaving
that field should trigger anything. On the wire it's a static enum.

The reason it can't is that options live in the value. SelectComponent<T> serialises
Options, and SelectedValue/SelectedValues are [JsonIgnore], derived from IsSelected.
Two consequences:

  • The user's choice has no independent representation. Storing "picked hevc_nvenc" means
    storing every encoder the probe found.
  • Probe output becomes configuration. settings-server.json holds a snapshot of whatever
    ffmpeg reported at the last probe, reloaded next boot on a machine where the binary may
    have changed.

From the definition's side a select is a value shaped like a list, which is why
UiSelectElement carries nothing but SelectType and MultipleItems.

The other split is the value holding the selection and the definition holding the option
source — inline when static, reactive when not. Either way the element needs to name what it
depends on:

public IReadOnlyList<string>? DependsOn { get; init; }

so a client can disable and say "enter the ffmpeg path first" rather than showing an empty
dropdown.

Conditions are one path, one value, one equality

[Visibility(DisableWhenMemberIsSet = nameof(FfmpegPath), DisableWhenSetTo = null)]
public SelectComponent<string> VideoEncoder { get; set; } = new();

Correct until someone focuses the path field and clears it. The value is then "" and the
encoder dropdown re-enables against a blank path. There's no emptiness test.

[Visibility(DisplayVisibility.Hidden,
    ToggleWhenMemberIsSet = nameof(HardwareAcceleration),
    ToggleWhenSetTo = HwAccelKind.Vaapi,
    ToggleVisibilityTo = DisplayVisibility.Visible)]
public SelectComponent<string> RenderDevice { get; set; } = new();

This also needs to show for Qsv. With one equality per element the workaround is a separate
property per acceleration mode.

public UiConditionOperator Operator { get; init; }   // equals, notEquals, contains, isEmpty, greaterThan, …

with Toggle/Disable taking a list plus all/any. Flat operators rather than JSON
Forms' schema-fragment approach, since ToggleWhenMemberIsSet/ToggleWhenSetTo can't emit a
fragment without getting unpleasant to author.

No element for a server-enumerated keyed set

[Visibility(DisplayVisibility.ReadOnly)] public Dictionary<Guid, bool> TransformEnabled { get; set; } = [];
[Visibility(DisplayVisibility.ReadOnly)] public List<Guid> TransformPriority { get; set; } = [];
[Visibility(DisplayVisibility.ReadOnly)] public Dictionary<Guid, bool> ObserverEnabled { get; set; } = [];

ReadOnly not because they're immutable — VideoStreamController mutates them through its
own endpoints — but because a record of raw GUIDs with checkboxes is unusable. What they
are is a reorderable enable-list over a set only the server can enumerate. Same gap as the
select, in core rather than a plugin.

@harshithmohan

harshithmohan commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

Proposal: a merge-friendly response model for LiveEdit

Following up on @da3dsoul's reactivity points — that comment covers when a client should call LiveEdit; this is about what comes back, because the current response shape makes the client's merge step heuristic instead of mechanical.

The problem

ConfigurationActionResult.Configuration is documented as "partially or fully sent back to the client depending on what the result is for." Concretely, after

POST /Configuration/{id}/LiveEdit?reactiveEventType=edited&path=Country

the client receives "configuration": { "State": { … } } and has to answer three questions with no data to answer them from:

  1. Is this the whole document or a subtree? Missing keys could mean "unchanged" or "this was a full doc without them" — and since the serializer omits default-valued members, absence is already overloaded.
  2. How do I express removals? When a handler clears a selection or empties a list, an object delta can't distinguish "removed" from "unchanged" from "set to null."
  3. Which response is newest? Debounced edits race; two in-flight LiveEdits can resolve out of order, and the older response's delta silently overwrites newer state.

Each of these currently resolves by per-endpoint folklore in every client.

Proposed shape

// POST /Configuration/{id}/LiveEdit?reactiveEventType=edited&path=Country&return=full
{
  // ── merge envelope ──
  "revision": 42,                // monotonic per config per server session
  "returnMode": "full",          // echoes the ?return param — never inferred
  "configuration": { … },        // returnMode=full: the ENTIRE document

  // when ?return=patch instead:
  // "patch": [
  //   { "op": "replace", "path": "/State/options", "value": [ … ] },
  //   { "op": "remove",  "path": "/State/options/0/selected" }
  // ],

  // ── existing side-channels, unchanged ──
  "validationErrors": null,
  "messages": [],
  "redirect": null,
  "refresh": false,
  "showSaveMessage": false
}

Rationale per piece

  • Caller-chosen return mode, echoed as returnMode. Small plugin configs ask for full — merging becomes setDraft(response.configuration), no patch machinery needed. Large configs (ServerSettings) ask for patch. The server never picks; the client never guesses.
  • RFC-6902 ops for patch mode. remove finally means remove (vs. absent/null/false soup against an omit-defaults serializer). JSON pointers are exactly the dotted element-key paths already used for value binding (/AniDb/UDPPingFrequency), so no new path convention. And the API already speaks this dialect — PATCH /Configuration/{id} takes a JsonPatchDocument — so it's the same vocabulary server-side.
  • Monotonic revision. The client keeps lastApplied and drops anything ≤ it before touching state. Without this, no debounce strategy is safe under concurrent mutations.
  • (Deliberately no request-correlation echo.) In a synchronous request–response the client already knows which call a response belongs to; correlation fields would only matter if LiveEdit ever moved to a decoupled transport (e.g. SignalR), at which point they'd be added there.

What the client reduces to

onSuccess: (res) => {
  if (res.revision <= lastApplied.current) return;   // drop stale
  lastApplied.current = res.revision;
  setDraft(res.returnMode === 'full'
    ? res.configuration                              // replace
    : applyPatch(draftRef.current, res.patch));      // merge
  if (res.validationErrors) setErrors(mapToElements(res.validationErrors));
  res.messages.forEach(toast);
}

One branch on returnMode, one guard on revision, zero endpoint-specific knowledge.

Scope note

This only reshapes the LiveEdit (and optionally action-result) response envelope — the request flow, reactive handler model, and UiDefinition are untouched, so it composes cleanly with the per-element reactivity proposal above: that one decides when clients call, this one makes what returns trivially applicable.

@hidden4003

Copy link
Copy Markdown
Member

Each Kind value maps 1:1 to an element-type renderer, and $refs are already resolved or hoisted into Definitions, so consumers don't need schema round-trips.

I like this shape more than the object that we would have to loop over and build this shape internally anyways

@revam

revam commented Sep 18, 2026

Copy link
Copy Markdown
Member Author

Pushed, rebased onto master. Replies to each of the four threads below.


On grouping the structure by section

This is closer to what I had in mind originally. The mandatory hoisting into a default section wasn't the design, it was an implementation constraint of rendering straight off the JSON schema and doing all the grouping client-side. structure was added to start digging out of that, but never expanded on. This finishes it.

Grouping is compiled server-side now, but positionally rather than by sweeping loose members into a default section. A container has Items, Actions and FloatingSections, plus a Structure listing entries of kind item, action or floating-section, each naming the map to look it up in. Members render in the order they were authored: a nested class stays an item and labels itself, [SectionName] gathers members into a floating section entered at the first of them, and anything unnamed stays where it is. A tab container gathers what's left over, because a tab needs a label. AppendFloatingSectionsAtEnd moves the gathered ones past the rest.

SectionName is off elements and actions, and DefaultSectionName and AppendFloatingSectionsAtEnd are off the wire, since the structure says what they said. ServerSettings comes out with the same tabs it has today, plus Misc. and Web UI.

Pinned buttons hoist out of the flow into StartActions / EndActions on the container or the section, and [CustomAction(AttachToMember = …)] puts a button on that member's row through AttachedStartActions / AttachedEndActions. So a client never filters by Position, and Position itself is off the wire.

A gathered section can also carry a description now, declared once on the class with [FloatingSection("Login", Description = "…")], since there's no type of its own to hang it on.


On the renderer findings

  • ID → Name: done, with docs saying it's the member behind the action and goes back as actionName.
  • Descriptions: fixed. The generator collapsed newline runs to a single \n and kept the indentation behind it. CleanDescription already did the right thing, but only ran on descriptions read straight off a member. Enum definitions were filtered out of the pass entirely, and types the generator doesn't describe, such as SelectComponent<T>, were skipped by two early continues. All four goldens regenerated; ServerSettings alone had 77 descriptions with a stray newline or an untrimmed end, and there's a test pinning it now.
  • path on the action: declining this one. A definition describes a type, not an instance. The same container repeats as a list item or a record value, where the path differs per row, so there's no path to put on it. The client tracks what it descended into. Documented on UiAction.
  • Condition paths: inside a list item or record value a condition resolves against that one instance; it evaluates against the client's current draft, not the saved document; and it can't climb out of its enclosing object, for the same reason as above. It can descend, though. A path may be dotted, spelled $"{nameof(Nested)}.{nameof(NestedConfiguration.Mode)}", which stays a constant and survives a rename. A path through a list or dictionary is rejected, since it carries no index.
  • Conditions also grew operators: Equals, NotEquals, IsEmpty, IsNotEmpty, In, NotIn, GreaterThan, LessThan, Contains, with Equals the default. InverseCondition is gone from the wire and from both attributes, because every use of it was a negated operator. A client meeting an operator it doesn't know should treat the condition as unmet.
  • [DataType(DataType.Password)] is read now. It was silently rendering as a plain text field; only [PasswordPropertyText] produced a password element, which the configuration README had been recommending against. Secret masking, the settings dump and the omission guard follow both markers now, so a property shown as stars is no longer serialized in the clear.
  • Flag enums: still unspecified. Nothing ships one, and I'd rather pin the convention when something does than guess now.

On reactivity

  • Per-element reactivity: landed. A container carries HasLiveEdit (this class handles live edits) and HasNestedLiveEdit (something below does), and each floating section carries HasNestedLiveEdit, so one tab can react while its siblings don't. On top of that a handler can narrow itself:

    [ConfigurationAction(ConfigurationActionType.LiveEdit,
        Events = [ReactiveEventType.Unfocused, ReactiveEventType.Edited],
        ReactiveMembers = [nameof(FfmpegPath), $"{nameof(Nested)}.{nameof(NestedConfiguration.Mode)}"])]

    which reaches the element as ReactsToLiveEdit: the events an edit to it is worth sending on, empty when nothing watches it. Leave both off and the handler keeps its old reach, meaning its own class and everything below it without a handler of its own. That turns "post 112 KB on every keystroke or never call it" into "post on blur, for these two fields".

  • ReactiveEventType conflating two roles: fixed the way you'd expect. Events replaces the singular property, so the handler side is plural and the request stays one event. No renumbering, nothing [Flags]. Dispatch prefers a handler that named the raised event over one that named none.

  • Conditions: the operators are the answer to the FfmpegPath and RenderDevice examples. IsEmpty catches a cleared field holding "", and In takes ToggleWhenSetToAny = [Vaapi, Qsv] instead of a property per mode. Combining several conditions with all/any I've left alone: attributes can't nest, so it needs repeatable condition attributes, and that's a bigger authoring change than these two cases justify.

  • Two new analyzer rules, so none of this waits for startup to tell you. SHOKO0006 covers a condition that could never hold, SHOKO0007 a handler watching a member that isn't there, a name pointing through a collection, or a hook no event raises trying to narrow itself. The runtime checks stay for plugins built without the package.

  • Selects: keeping the component as-is. The definition describes the shape, not where options come from, which is what makes it a component. The storage concern has two answers already: keep the choice in its own property with the SelectComponent nullable, populated in a Load or LiveEdit handler and nulled once chosen; or strip the selector in a Save handler before it persists, since a custom save owns persistence entirely. Either way the id persists, the options never do, and the selection survives an option disappearing. Written up in Shoko.Abstractions/UI/README.md.

  • The hook dispatch was broken. Every handler lookup asked for a LiveEdit handler whatever hook was being run, so Load, New, Save and Validate handlers never resolved. A class declaring one had its live-edit handler run instead, or nothing at all. GET /Configuration/{configID} answered 409 for a Load handler, and a custom Save handler silently persisted nothing. It's on master too, but this area is effectively untested in public and nothing in any published plugin hits it to date. Fixed here, with tests, since it's the mechanism behind all of the above.

  • The server-enumerated keyed set is still open, and is the one item from that list I haven't touched.

One source break worth flagging: dropping InverseToggleCondition affects the offline importer, which uses it for the selector toggle. It becomes ToggleOperator = UiConditionOperator.IsNotEmpty with the ToggleWhenSetTo = null line removed.


On the LiveEdit response envelope

The premise is off by one layer. ConfigurationActionResult.Configuration is the plugin-facing type, but the endpoint returns the v3 DTO, which diffs the handler's result against the document you posted and sends PatchOperations, being Microsoft.AspNetCore.JsonPatch ops. So patch mode, real remove semantics, and "subtree or whole document" are already settled: it's always a patch against what you sent.

What stands is the ordering guard, and I'd rather offer it than assume it. A live edit persists nothing, so the only thing mutating that draft is the client that issued the request, and a debouncing client already knows the order it sent things in and can drop a response older than its newest in-flight one. A server counter earns its place once something else can change the document mid-edit, which nothing can today. Say the word and I'll add a monotonic revision, comparable within one session and one configuration.

Worth noting that the reactivity work above shrinks the problem too: with ReactiveMembers and Events, a client posts on blur for two fields rather than on every keystroke across the form, so there's far less in flight to race.

A caller-chosen full mode I'd skip unless you want it, since it saves a small config from applying two ops.


Also in this push: Shoko.Abstractions/UI/README.md, covering layout, conditions, the lifecycle hooks and the patterns worth copying, plus corrections to a few things the configuration README said that this PR made untrue.

@revam

revam commented Sep 19, 2026

Copy link
Copy Markdown
Member Author

Five more commits, from walking the RFC over on the WebUI side. Two of them correct things I said in the comment above, so they are worth calling out rather than leaving in the diff.

A select's value is cased like the rest of the document now. SelectComponent<T>, SelectOption<T> and SelectGroup pinned their JSON names to lowercase to match the schema they used to be described by. The definition carries the presentation now, so the only thing the pinned names still did was make this one element's value the exception in a document where every other property serialises under its declared name. It is Options, Groups, Label, Value, IsSelected, IsDefault, IsDisabled, GroupID now. I said in the update above that this was staying as it was; it is not.

auto no longer reaches the wire for a layout either. That claim only ever held for Kind: ListType and RecordType were still served as auto, leaving a client to work out from the item what the server already knew. Both resolve before serving now, which needed a name for the layout that never had one, so DisplayListType and DisplayRecordType gained Flat. An entry that is a class of its own lays out one per entry, anything else is a plain row, and a select follows whether it takes more than one value. An authored layout is never overridden, so a list of enums stays flat unless the class asked for EnumCheckbox.

A row can say what it is called. TitleComponent, holding Title and SubTitle, which a list points at through ItemTitlePath and ItemCategoryPath instead of guessing from the names of an item's other members. UiRecordElement gained the same two paths, so a dictionary's values are labelled like a list's entries, and a record entry with neither is labelled by the key it is stored under. The [Key] fallback stays for a class that says nothing.

Denied values are filed on the element that can act on them. [DeniedValues] was copied onto whatever element the property produced, so a List<T> claimed the list itself must not equal one of the values, and a nested class or a select carried values neither could ever match. They are hoisted onto a list's item and a record's value now, and left off anything with no value of its own to match.

Every live-edit handler on the edited path runs, innermost first. Only the one nearest the edit used to, so a class coordinating what its nested types do never heard about an edit to one of them. Each handler is handed what the one before it returned, on the context and as an injectable parameter, and what each returned is collected rather than replaced: messages in run order, validation errors merged by key, the first redirect, the flags or'd. They share the configuration instance, so the document itself needs no merging and the response is still one patch. The chain is narrowed to handlers whose declared events and ReactiveMembers cover the edit.

A live edit against a path that does not resolve is a no-op rather than a 400. That is what a row still being composed client-side looks like, and it is the best-effort question from the reactivity thread. An explicitly invoked action still fails, because somebody pressed a button on something that is not there.

@revam
revam force-pushed the feat/gen-ui-dto branch 2 times, most recently from 5fc112f to c351a04 Compare September 20, 2026 16:31
@revam
revam force-pushed the feat/gen-ui-dto branch 5 times, most recently from dacef55 to 0669a1b Compare October 8, 2026 23:06
revam added 12 commits October 9, 2026 07:39
… definition

A client that wanted to render a configuration form had to read two
documents and reconcile them: the JSON Schema for structure, and the
`x-uiDefinition` extension bag for presentation. JSON Schema is a validation
vocabulary, so presentation had to be inferred from it — `elementType: "auto"`
was literally an instruction to go and read `schema.type` — and references
were left unresolved, so the client dereferenced `$ref` itself while
carefully preserving the UI metadata a standards-compliant dereferencer would
discard. Executable actions had it worse: `ActionService` could deserialise a
parameter payload straight onto an action instance, but nothing described the
parameters and the API exposed no way to send them, so an action with
parameters could only be invoked in-process, on its defaults.

**Added `Shoko.Abstractions.UI.UiDefinition`**, a description of a form shaped
for rendering: an abstract `UiElement` with a concrete `Kind` discriminator
and 15 sealed element types, `$ref`s resolved, labels pre-resolved, and
constraints, nullability and requiredness on the element that needs them.
`auto` never reaches the wire.

- `GET /api/v3/Configuration/{configID}/UiDefinition` returns a
  configuration's form, cached on `ConfigurationInfo`
- `GET /api/v3/Action/{actionID}/UiDefinition` returns an action's parameter
  form, for actions of any scope
- `POST /api/v3/Action/{actionID}` and the four scoped variants accept an
  optional parameter body, validated against the action's schema before
  anything runs, with unknown properties rejected
- `ActionInfo.HasParameters` tells a listing whether an action has a form
  worth opening
- A container's `Items` and `Actions` are maps keyed by what a `Structure`
  entry carries, and enumerate in authored order

**Cut `x-uiDefinition` down to what the validator reads.** The bag existed to
carry presentation, which `UiDefinition` now does. What remains is the five
entries `ShokoJsonSchemaValidator` reads back during validation, and a
property carrying none of them gets no bag at all. `ServerSettings`' schema
dropped from 5567 to 2703 lines. This is breaking for any client rendering
off the bag.

**Added `Shoko.BuildTools.Analyzers`**, a Roslyn package that turns the shapes
the generator cannot describe into compiler errors (SHOKO0001–0005) at the
authoring site rather than exceptions at startup. It walks out from
`IConfiguration` and `IExecutableAction` roots only, so an identical shape
elsewhere is untouched. The runtime checks were kept, so a plugin can still
be built without referencing the package.

**Moved the shared authoring surface to `Shoko.Abstractions.UI`** —
presentational attributes, the `Display*` enums and `SelectComponent<>`.
`Config.Enums` was already being imported by the UI namespace, so the
dependency pointed the wrong way. Configuration-lifecycle attributes stayed
in `Config`.

Fixed along the way: inherited configuration properties emitted no UI
metadata at all, `visibility.toggle.inverseCondition` was never emitted
though it is read as non-optional, and `DisplayButtonPosition` aliased
`Top`/`Left` and `Bottom`/`Right` onto shared values with `[EnumMember]` only
on the canonical members, so `Position = Top` serialised as `"Left"`. The
aliases were removed; `Auto`/`Start`/`End` remain.
…ing sections

Rendering a form meant deriving its grouping first. Membership was stated
twice — once as `SectionName` on every element and action, once implicitly
through the authored order in `Structure` — so a client reconciled the two by
filtering every element on every render, at every container depth.

- Replaced `UiSectionContainerElement.Structure` with a render order whose
  entries name which map to look the member up in: `Items`, `Actions`, or the
  new `FloatingSections`
- Added `UiFloatingSection`, carrying a group's title, description, the actions
  pinned to either end of it, and its own `Structure`
- Gathered members sharing a section name into one section, entered at the
  first of them, and left every other member where it was authored, so a field
  set can go item, item, nested container, item
- Gathered the loose members of a `tab` container, and of a class that named
  its default section, since a tab has to have a label. A nested container is
  never gathered; it labels itself from its own element
- Dropped `SectionName` from `UiElement` and `UiAction`, `DefaultSectionName`
  and `AppendFloatingSectionsAtEnd` from the container, and `Position` from
  `UiAction`. Each said again what the structure now says, and the authored
  `AppendFloatingSectionsAtEnd` only decides where the gathered entries sit
- Dropped `CustomActionAttribute.AttachToMember` and `UiAction.MemberName`:
  nothing set them, and nothing said what a client should draw for them
- Documented that a `UiCondition` resolves against the one list item or record
  value it sits in and cannot reach outside it, and that an action carries no
  invocation path, because the container it belongs to can repeat
A summary is indented and line-wrapped for whoever reads the source, and both
reached the client verbatim: every description ended in a newline and the
author's indentation, and any summary wrapped across lines broke mid-sentence
at whatever column it happened to wrap at. `CleanDescription` has always
handled this — it unwraps single newlines, keeps a blank line as a paragraph
break and trims the ends — but only the descriptions read straight off a
member went through it.

- Swapped the generator's own newline collapsing, which kept the newline and
  the indentation behind it, for `CleanDescription`
- Cleaned the descriptions of enum definitions, which were filtered out of the
  post-processing pass before it ever looked at them
- Cleaned the descriptions of a definition the generator does not describe,
  such as `SelectComponent<T>`, by moving the property pass ahead of the
  lookups that skip one
- Regenerated the four golden schemas; `ServerSettings` alone had 77
  descriptions carrying a stray newline or an untrimmed end
A configuration action is identified by the name of the method behind it and
goes back as the `actionName` of a `PerformAction` call, while an executable
action is identified by a `Guid`. Both sat under `ID`, so a client holding one
of each could not tell which contract a value belonged to.

- Renamed the property and said in its docs what the value is and where it
  goes back to
Every handler lookup in `GetContextualTypeForConfigurationInfo` asked for a
`LiveEdit` handler, whatever hook was being run; the `actionType` argument only
decided whether to look at all. A class declaring a `Load`, `New`, `Save` or
`Validate` handler therefore had its live-edit handler run in its place, or
nothing resolved and the caller silently got an empty result — and
`GET /api/v3/Configuration/{configID}`, which reaches that call only once
`HasCustomLoad` says there is a handler, answered 409 `Unable to load custom
configuration object for the user`.

Nothing in-tree declares these hooks, so only a plugin would have hit it.

- Folded the three duplicated lookups into `FindReactiveHandler`, which matches
  the hook being run
- Kept the event matching for `LiveEdit` alone, since it is the only hook an
  event raises: the handler for the event wins, and one that took everything
  stands in for it
- Added `ReactiveHandlerDispatchTests`, covering one handler per hook, a class
  that declares only a live-edit handler, and the event fallbacks
`ConfigurationInfo.HasLiveEdit` was one bool for a whole configuration, so a
client had two options: post the entire document on every event on every field,
or never call `LiveEdit` at all. For `ServerSettings`, at ~112 KB, that is the
second, and the mechanism goes unused.

- Added `HasLiveEdit` to a section container, set when the class behind it
  declares a live-edit handler
- Added `HasNestedLiveEdit` to a section container and to a floating section,
  folded over what each holds, so one tab can react while its siblings do not
  and a client can skip a whole branch
- Counted a reference as reactive, since it stands in for a type still being
  walked and an edit nobody posts is worse than one nobody wanted

A handler is still declared on the class rather than on the member, so this
says a container reacts, not which of its members it watches.
`AttachToMember` was dropped a few commits ago as unused, which was wrong:
`Tsumugu` attaches a refresh button to the path field it refreshes, and the
`Position = End` on it is what puts the button after the field rather than
among the class's own members. Nothing described what a client should draw for
it, so this says it.

- Restored `AttachToMember`, documented as the member whose row the action
  renders on, with `Position` choosing the edge of that row
- Added `AttachedStartActions` and `AttachedEndActions` to every element,
  holding the names the container files those actions under
- Left an attached action out of the container's structure and out of its
  sections, so it is described once and drawn once, on the row it names
- Kept an action naming a member the class does not have among the rest,
  rather than have it vanish
- Dropped `MemberName` from `UiAction` again: the element the action hangs off
  now says it, and saying it twice is what the structure work removed
Rebasing onto master brought `AnilistSettings` and the airing schedule
settings in, and both are described like everything else.

- Regenerated the `ServerSettings` golden schema
- Added the `Anilist` tab to the tabs the definition test pins
The authoring surface moved into `Shoko.Abstractions.UI` and grew a render-ready
`UiDefinition` beside it, and neither had a document to point a plugin author
at. The configuration README covers declaring a class; this covers what the
server makes of it.

- Added `UI/README.md`: what a definition holds, how members are laid out and
  grouped, where a button lands, the element kinds, how conditions resolve, the
  lifecycle hooks and the live-edit flags
- Wrote up three patterns worth copying: a choice only the server can enumerate,
  where the selection persists and the options never do; an action refreshing
  the field it sits on; and a list whose rows label themselves
- Pointed the configuration README at it, and corrected three things it says
  that are no longer true: the attributes live in `UI/Attributes/`,
  `DisplayButtonPosition.Top` is gone, and `[DataType(DataType.Password)]` is
  not read — only `[PasswordPropertyText]` produces a password element
A section members are gathered into is not a type of its own, so it had nowhere
to carry a description and `UiFloatingSection.Description` was always null. The
class is the one place that can say it once.

- Added `[FloatingSection("Login", Description = "…")]`, declared on the class
  next to `[Section]`, and carried its description onto the section
- Left ordering alone: a section still follows the first member in it, so there
  is one rule rather than two that can disagree
- Ignored a described name no member uses, since the section it would describe
  has nothing in it
- Recognised `[DataType(DataType.Password)]` as a password element, which every
  data-annotations author reaches for before `[PasswordPropertyText]`, and which
  silently rendered as a plain text field
revam added 26 commits October 9, 2026 07:39
`ValidateParameters` sat on the concrete `ActionService`, on the reasoning that
only the API boundary parses a document and an in-process caller passes a typed
dictionary instead. The premise was wrong: a plugin serves its own routes under
`/api/plugin/<namespace>/`, so it is an API boundary in exactly that sense, and
it holds only `IActionService`. The split was core versus plugin rather than
in-process versus parsed, which is not a line this abstraction draws.

The asymmetry was the sharp kind, too: a plugin could already render an action's
form, since `ExecutableActionInfo.ParameterDefinition` carries the same
`UiDefinition` core uses, and only checking what came back was withheld.

- Lifted `ValidateParameters` onto `IActionService`, where `JObject` and the
  errors-by-path dictionary are both established precedent
- Put the action controllers back on `IActionService`
- Kept the guidance about which overload an in-process caller wants, since that
  part was useful, without the claim that it justifies withholding the method
`EnableAPIv1` came off `WebSettings` when APIv1 was removed on master, so
the golden baseline no longer matched. Regenerated with
`SHOKO_UPDATE_GOLDEN=1`; the only change is that property dropping out.
…scheduled action work

- Pointed the new `MetadataSettings`, `MetadataImageSettings` and `MetadataServiceSettings` at `Shoko.Abstractions.UI` for their presentational attributes
- Dropped the usings the rebase left unused in the settings it touched
- Moved the action tests off `DownloadAllImagesAction` and `PurgeAllTmdbLinksAction`, which are scheduled actions now, onto the delete and linked-metadata actions
- Replaced the `Anilist` tab with `Metadata` in the tabs the definition test pins
- Regenerated the `ServerSettings` golden schema
- Pinned the unwrapped description in `SchemaDescriptionTests`, since `CleanDescription` joins a wrapped summary into one line
…nd action work

- Ran the analyzer on the bundled `Shoko.Plugin.Tmdb` and `Shoko.Plugin.WebAOM` as well
- Swept the bundled plugins' configurations and every in-tree executable action through the builders
- Dropped `PasswordSchemaTests`, which read `elementType` off `x-uiDefinition`; the `[DataType]` test covers it
- Dropped the `TMDB` tab from the pinned tabs and followed the reworded `Enabled` description
- Regenerated the `ServerSettings` golden schema
…ta work

- Pointed `MetadataSourceOverrides` at `Shoko.Abstractions.UI` for `[Visibility]`
- Read the member order in `StaticMemberSchemaTests` off the typed builders, since `x-uiDefinition` no longer carries `structure`
- Restored master's `<c>null</c>` comment in `SchemaDescriptionTests` and dropped an unused using
- Regenerated the `ServerSettings` schema golden
- Added `[OptionsProvider]`, naming a method on the declaring class that returns a collection of the member's value type, or of `SelectOption<T>` for labels, directly or through a task
- Checked the method while the schema is generated, so a misfit fails startup
- Gave `UiElement` an `OptionsRoute` and served it from `POST /api/v3/Configuration/{configID}/Options` and `POST /api/v3/Action/{actionID}/Options`
…member

Added SHOKO0008, mirroring the startup check of `[OptionsProvider]`.
- Removed `CustomActionAttribute.AttachToMember`, `UiElement.AttachedStartActions` and `UiElement.AttachedEndActions`
- Custom actions now render only in their container's own order
…xecution sees it

- Prepared the instance the way validation does: scoped, given its caller and populated
- Added scoped overloads of `GetParameterOptionsAsync` and `…/{entityID}/Action/{actionID}/Options` routes for groups, series, episodes and files
- Refused an inapplicable action, a hidden entity or a bad path with a `GenericValidationException`
…r any number of members

- Moved `[OptionsProvider]` onto the method, naming the members it lists options for
- Refused an unknown member, members of different option types and a member claimed twice, at startup and in SHOKO0008
…only of types told apart as text

- Added `OptionsProvider.Target` (`OptionsTarget.Values`, `OptionsTarget.Keys`); routes now sit on the entry, value or key element, keys under `…/Options/Keys`
- Restricted option types to primitives, enums, common text round-trip types and any type with `IParsable<TSelf>` or a `[TypeConverter]` to text or a primitive
- Answered a provider's `GenericValidationException` with a validation problem on every options route
- Made `UiOption.Label` always set, skipped nulls and kept duplicates
- Split the options checks into SHOKO0008 to SHOKO0013
- Added `Shoko.BuildTools.Analyzers/README.md`, one section per rule, linked from each rule's help link
- Rewrote the UI README's options section
A member of a plugin type implementing `IParsable<TSelf>` without a `[TypeConverter]` rendered as a section; it now renders as text, like a converted type.
- Labelled an enum option with the name the UI definition gives its member, unless the provider gave one
- Dropped the `IEquatable<TValue>` constraint from `SelectOption<TValue>`, so enums can be labelled too
- Noted that providers return the full list and clients filter locally
- Wrote a `[Flags]` enum as an array of its single-bit member names, through `FlagEnumNewtonsoftConverter` and `FlagEnumSystemTextJsonConverter` in `ShokoJsonSerializers`, action parameter population and the server settings; the old text and number are still read, and combined members expand
- Described it as a unique array of an enum of those names, rendered as a `List<TEnum>`; dropped `IsFlag`
- Validation reads the old forms into the list; options providers list its entries
- Refused lists of flags enums, flags enum keys and flags enums without single-bit members (new SHOKO0014)
- `OptionsRoute` is now `Options` or `Options/Keys`, appended to the configuration's route or the route the action is invoked on
- Dropped the templated scoped action routes and their entity placeholders
…e` with `HasOptions`

- `UiElement.HasOptions` says the server lists options; clients always POST to `<form route>/Options`
- A dictionary's path lists its keys, `Dict["key"]` the values for that key; the `/Options/Keys` routes and the services' `target` parameter are gone
- `[OptionsKey]` hands a values provider the entry's key, checked at startup and by SHOKO0015
- Added `ParsableNewtonsoftConverter` and `ParsableSystemTextJsonConverter`, writing an `IParsable<TSelf>` type with its invariant `ToString()` and reading it with `TryParse`
- Registered them next to the flag enum converters; a type with `[TypeConverter]`, its own `[JsonConverter]` or of a `System` namespace is left alone
- A typed schema default of such a type is written as its text
- `PopulateParameters` turned a converter's `JsonException` into a `GenericValidationException` keyed by the value's path
- The five invoke routes answered it as a validation problem
- `ReactiveEventType` serialised as its kebab-case names in both serialisers
- `EnumMemberModelBinder` binds an enum's wire names from route, query and form values, C# names still accepted
- `ConfigurationService` validation runs the hook after the schema, so `Save` and the `Save` hook are refused alike
- `PATCH` with a `Save` hook validates first too
- Schema and definition defaults read property initialisers, `[DefaultValue]` still wins
- Action parameters take theirs from the registered probe instance
- Secrets and nested classes carry no default; goldens regenerated
…meters

- `/Options` routes skip whole-draft validation; only the asked parameter must read
- `[Required]` and `required` parameters are marked and enforced, the rest stay optional
- Invalid options paths and keys answer errors keyed by the requested path
- `[Section(DisplaySectionType.Checkbox, ToggleMember = nameof(Enabled))]`, exposed as the container's `ToggleMember`
- Startup check and `SHOKO0016`: a checkbox section names a serialised bool member, no other section names one
@revam
revam marked this pull request as ready for review October 11, 2026 17:12
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants