Repository navigation
Dynamic UI RFC #1445
Replies: 8 comments
|
There's a draft PR on the server side that this RFC needs to account for: What it is: a This replaces This is a Against the proposed build order1. Collections follow the same vocabulary: a 2. 3. 4. 5. 6/7. Cross-cutting behavioursAll first-class fields:
On the action modalThe RFC lists that consumer as blocked on a separate plugin-action RFC. On the
Two shipped actions already declare parameters ( SequencingLess open than it was. Steps 2–4 are where the old bag was most awkward — If anything in the DTO shape is awkward to render, now is the free time to change |
|
Follow-up to revam's comment above: two shape proposals from walking We took a live Current shape (as of PR
|
|
Update: the DTO shape changed after the review round The feedback in this thread and on the PR landed, and the shape a renderer reads is meaningfully different from what I described above. The branch is rebased onto Grouping is compiled on the server now, and positionally. A container files its members in three maps and puts them back in order with // GET /api/v3/Configuration/{configID}/UiDefinition → Root.Items.AniDb
{
"Kind": "section-container",
"SectionType": "minimal",
"ShowSaveAction": false,
"HasLiveEdit": false,
"HasNestedLiveEdit": false,
"Items": { "Username": { … }, "Password": { … }, "AVDump": { … } },
"Actions": { "Test": { "Name": "Test", "Title": "Test", "Theme": "primary", … } },
"FloatingSections": {
"Login": {
"Title": "Login",
"Description": null,
"HasNestedLiveEdit": false,
"StartActions": ["Test"],
"EndActions": [],
"Structure": [ { "Name": "Username", "Kind": "item" },
{ "Name": "Password", "Kind": "item" } ]
}
},
"StartActions": [], "EndActions": [],
"Structure": [ { "Name": "Login", "Kind": "floating-section" },
{ "Name": "Download", "Kind": "floating-section" },
{ "Name": "MyList", "Kind": "floating-section" } ]
}So the render loop is: walk What changed against what I described earlier:
On @harshithmohan's follow-up
For the RFC's build order, the practical differences are: The PR description is updated to match, and three things are deliberately still open there: an element for a keyed set only the server can enumerate, an optional |
|
Follow-up to the update above: five more commits, and two of them contradict what I told you. Anyone building against the shape described in my previous comment should read this first. 1. A select's value is cased like everything else now. I said the lowercase field names were staying, on the grounds that they matched the schema's casing. They are gone: the definition carries the presentation now, so matching the schema bought nothing and left this one element's value as the exception a generic value binder gets wrong. The bound value reads: "ManagedFolderSelector": {
"Options": [
{ "Value": 1, "Label": "Anime", "GroupID": null, "IsSelected": true, "IsDefault": false, "IsDisabled": false }
],
"Groups": []
}So the binding layer needs no special case for it, which is the opposite of the warning in the RFC's 2. 3. Rows can say what they are called. A class can hold a 4. Denied values now appear only where they can be applied. They used to be copied onto every element, including a 5. Live edit answers two of the RFC's open edge cases. Every handler on the edited path runs now, innermost first, each handed what the one before it returned, with messages and validation errors collected across all of them and the document merged by construction. So a parent type does see an edit to a nested one. And a live edit naming a path that does not resolve is a no-op rather than a 400, which is the not-yet-added row case: treat reactive requests as best-effort. An explicitly invoked action is still strict. Still open on the server side, unchanged: an element for a server-enumerated keyed set, the optional For what it is worth, we have an internal harness that renders a configuration from the definition alone, with no per-configuration code, and a test that walks a live |
|
A Concepts section for the RFC, as requested. This is meant to slot into the RFC after Summary and before Motivation, so a reader picks up the vocabulary before the argument for it. It describes the shape as of the current tip of ShokoAnime/ShokoServer#1414, including the two corrections in my previous comment. Copy it in as-is, or tell me what to change. ConceptsThree documents are involved, and keeping them apart is most of understanding this.
A renderer needs the first two. The schema stays the authority for server-side validation, and a client built on the definition never has to read it. Type, not instance. A definition is generated once per type, before anyone's saved document exists, so it cannot say how many rows a list has, what keys a dictionary holds, or where any particular row sits. Three consequences that come up repeatedly:
Element. The unit of the definition. Every element carries a Container and structure. A Each entry names which map to look it up in, so resolving one is an index rather than a scan. Nothing is grouped, sorted or filtered client-side: the order in Two kinds of grouping, which are easy to confuse:
Action. A button the class declared. Where it renders is decided by which list names it, never by a property on the action: a container's or section's Live edit. A class can handle edits before anything is saved, to compute one field from another or fill a list of options. Because posting the whole document on every keystroke is not viable, the definition says where it is worth doing: a container carries What comes back is a result, not necessarily a document. It may carry JSON Patch operations against what was posted, and only does when the handler returned a configuration at all; it may instead, or additionally, carry messages to show, validation errors to attach to fields, a redirect, and flags such as whether to refresh. A handler that only validates, or only has something to say, returns no patch. The same result shape comes back from invoking an action, so one merge path covers both. Condition. A comparison the client evaluates against the draft the user is editing, not the saved document. It carries a path, an operator, and a value or set of values. It can descend into a nested object and never climbs out of the one it is declared in. An operator a client does not recognise is treated as unmet. Reference. Where the element tree would recurse into itself, the server emits a Value binding. An element's key is the property name in the values document, so an element's path is the chain of keys walked to reach it. One exception worth knowing: a Who decides what.
|
|
Update: options the server lists The first open question from the PR, "an element for a keyed set only the server can enumerate", is answered in ShokoAnime/ShokoServer#1414. Here is what a renderer needs to know. Authoring side. A method marked [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() => …;
On the wire.
What the client should do.
Also changed in this round. Actions attached to a member's row ( Test plugin. revam/dotnet-shoko-plugin-genui-test holds one configuration per level of reactivity (save only, conditions, custom actions, server options, live edit, lifecycle hooks, combined) and two executable actions, one global and one per series. Each member's description says what you should see, so it doubles as a checklist for the renderer. It is never released; build it against a checkout of the server branch with |
|
Closing out the Hi @revam, this is one of the last items on the RFC's Open questions list, and I'd like to settle it one way or the other. Where it appears in the RFC. The last bullet but two of "Open questions" says a monotonic revision counter for the live-edit/action-result response, so that a client can discard a stale, out-of-order reply, has been offered on the server side but not built. The Reactivity section describes the live-edit request and response, and says nothing about ordering. What I understand the position to be. In your PR comment on 2026-09-18 you said a live edit persists nothing, so only the client that issued the request can change the draft. A debouncing client already knows the order it sent things in and can drop a response older than its newest in-flight one. You also said a server counter earns its place once something else can change the document mid-edit, and that nothing can today. You offered to add a monotonic Current state. I checked PR head What I'm asking. Could you pick one of the following, so I can close the question in the next revision?
If no option is chosen, the next revision of the RFC will record option 3 and remove this item from the open questions. The server-side counter stays available as an offer if a need for it appears later. Update, 2026-10-11: Resolved with option 3, per the discussion on Discord. RFC v7 records the decision in the Reactivity section and no longer lists the counter among the open questions. No reply is needed here. |
|
ShokoAnime/ShokoServer#1414 is no longer a draft. If there's nothing else, it's ready to merge. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Dynamic UI (schema-driven parameter/form generation, rendering & validation, and conventions) — RFC (final)
Summary
Shoko already has a schema-driven UI mechanism: a plugin author decorates a C# configuration class with attributes, the server describes the resulting form, and the WebUI renders it — no hand-coded per-feature UI required. This exists end-to-end today, but coverage is thin: the WebUI implements only 3 of 9 documented element types, several cross-cutting behaviors the server can already express (visibility rules, badges, restart/env-var indicators) aren't rendered at all, and server-declared action buttons on a form are dropped entirely.
The server side of this contract is
ShokoServerpull request ShokoAnime/ShokoServer#1414, now merged. It replaces the JSON-Schema-plus-extension-bag approach (x-uiDefinition) with a dedicated, render-readyUiDefinitiondocument, served alongside the schema rather than folded into it. This RFC describes that merged shape, and is the reference the WebUI implementation will be built from.This RFC proposes:
UiDefinitiondocument describes (section-container,boolean,integer,float,string,text-area,password,code-editor,enum,list,record,select, plusreferenceandunknownas structural/fallback cases) and the cross-cutting behaviors it carries on every element (size, visibility, badges, restart/env-var indicators, denied values).Throughout, the plugin developer controls the resulting UI by authoring the schema/attributes; the WebUI's job is to render it generically, not to hand-build a component per plugin.
Concepts
Three documents are involved, and keeping them apart is most of understanding this.
GET Configuration/{id}/UiDefinitionGET Configuration/{id}GET Configuration/{id}/SchemaA renderer needs the first two. The schema stays the authority for server-side validation, and a client built on the definition never has to read it.
Type, not instance. A definition is generated once per type, before anyone's saved document exists, so it cannot say how many rows a list has, what keys a dictionary holds, or where any particular row sits. Three consequences that come up repeatedly:
Element. The unit of the definition. Every element carries a
Kinddiscriminator naming exactly one renderer, plus the same cross-cutting fields regardless of kind: label, description, size, visibility, badge, restart and environment indicators, denied values, default, required and nullable. A leaf element (string,integer,enum, …) binds to a value; a composite one (section-container,list,record) holds other elements.Container and structure. A
section-containerfiles its members in three maps and puts them back in order withStructure:Each entry names which map to look it up in, so resolving one is an index rather than a scan. Nothing is grouped, sorted or filtered client-side: the order in
Structureis the order on screen.Two kinds of grouping, which are easy to confuse:
[SectionName], plus a default section for whatever is left when the layout needs one. It has a title, an optional description, its ownStructure, and its own pinned actions. It is not a member of anything; it is a view over members.Action. A button the class declared. Where it renders is decided by which list names it, never by a property on the action: a container's or section's
StartActions/EndActionsfor a pinned button, or the structure itself for one inline among the fields. Invoking it needs the configuration id, the action'sName, and the path the client walked to reach it.Live edit. A class can handle edits before anything is saved, to compute one field from another or fill a list of options. Because posting the whole document on every keystroke is not viable, the definition says where it is worth doing: a container carries
HasLiveEditandHasNestedLiveEdit, and an element carriesReactsToLiveEdit, the events an edit to that element is worth sending on. Empty means the form stays local.What comes back is a result, not necessarily a document. It may carry JSON Patch operations against what was posted, and only does when the handler returned a configuration at all; it may instead, or additionally, carry messages to show, validation errors to attach to fields, a redirect, and flags such as whether to refresh. A handler that only validates, or only has something to say, returns no patch. The same result shape comes back from invoking an action, so one merge path covers both.
Options. Some choices are neither fixed by the type nor stored in the value: the set of installed plugins, the libraries that currently exist. An element the server can list such choices for carries
HasOptions, and the client asks for them on demand, sending the draft it holds; the answer is a list of value-and-label pairs. Like a live edit, it reads the unsaved draft, so the choices can depend on what the user just typed.Condition. A comparison the client evaluates against the draft the user is editing, not the saved document. It carries a path, an operator, and a value or set of values. It can descend into a nested object and never climbs out of the one it is declared in. An operator a client does not recognise is treated as unmet.
Reference. Where the element tree would recurse into itself, the server emits a
referencepointing into the definition's ownDefinitionstable. It is the only place a client has to resolve anything by name.Value binding. An element's key is the property name in the values document, so an element's path is the chain of keys walked to reach it. One exception worth knowing: a
selectelement is deliberately thin, because its options live in the value it binds to rather than in the definition, populated by the server.Who decides what.
Motivation
This is not a from-scratch design. A generic mechanism already exists and is already wired up end-to-end for one consumer:
GET Configuration/{id}/Schemareturns a JSON Schema, currently extended with anx-uiDefinitionproperty describing how each field should render — element type, size, visibility rules, badges, restart requirements, env var overrides, etc.GET Configuration/{id}returns the current values.src/components/Dynamic/{DynamicForm,DynamicSection,DynamicField,CodeEditor}.tsxconsume that contract generically (keyed by configurationid, not hardcoded to any one feature).src/pages/utilities/Renamer.tsx.The gap is coverage, not architecture: the WebUI's
elementTypeunion documents nine element types, but only three are actually rendered (auto, which itself branches internally on JSON type to cover booleans/numbers/strings,password, andcode-block) — everything else falls through to an "Unsupported element type" placeholder. Several cross-cutting behaviors the schema already models — visibility, badges, restart indicators, env-var overrides, denied values — aren't rendered at all yet.Why the RFC targets the
UiDefinitiondocument, not the schema bag. The bag is hard to render from. Before the merge, its generator had a real defect: for a plain scalar list (e.g.CoreSettings.Import.VideoExtensions, aList<string>), the field meant to carry the list's item element type was initialized to a display size value and only conditionally overwritten, so for a simple scalar list it never was. A client reading it to decide how to render list items would be reading a value that means something else entirely. The same construction existed forrecord.GET Configuration/{id}/UiDefinitionfixes exactly this: a list or record's item element is a real, always-present child node, not something inferred from an overloaded field. Section grouping is computed server-side and positionally, conditions have real comparison operators, elements say which live-edit events they care about, and the server can list choices only it can enumerate. The same document shape is also served for actions (GET Action/{actionID}/UiDefinition) and parameters are accepted on action-invocation endpoints, which gives the plugin-action-parameter consumer (this RFC's original motivating use case) a real contract.The legacy bag is still served for now. So that the released WebUI keeps working, the server still writes the few legacy
x-uiDefinitionentries today's dynamic form reads (sectionTypeandstructureon the root,elementTypeandcodeLanguageon its properties), for the renamer preset editor (and, per the server's own note, the metadata provider settings; this WebUI's only current consumer is the renamer page). The server marks this as legacy, to be removed once the WebUI moves toUiDefinition. New code should readUiDefinitiononly.This RFC's target consumers are:
Renamer.tsxalready uses to other plugins. Buildable now. One thing to know when listing configurations:GET Configurationexcludes hidden and base configurations unless asked, and on a running server every configuration reportedIsHidden: true, including the core settings and the test plugin's seven. With the default filters the list was empty. A page that lists configurations has to passhidden=True(andisBase=Truewhere it wants those), or fetch a known configuration by its id.GET Action/{actionID}/UiDefinition, plus parameter-accepting invocation endpoints) from Render-ready UI definitions for configurations and actions, plus a Roslyn analyzer for unrenderable shapes ShokoServer#1414, now merged; it returns the same document shape as the configuration endpoint.The design intent behind both: the plugin developer controls the UI, not the WebUI team — a plugin ships a description of its fields, and the WebUI renders it generically. The purpose of this RFC is to replace
src/pages/settings/plugin/PluginPageEmbed.tsx, which lets a plugin control its UI via a fully custom page embedded in an<iframe>(arbitrary HTML/JS). The iframe was the easiest way to render dynamic third-party UI in the WebUI, not a design to preserve: once this RFC is confirmed, iframe embedding should be considered legacy.Also distinct from both of the above: a configuration class can already declare UI action buttons on its own settings form (e.g. "Test Connection"), separate from the action-modal consumer described above. The WebUI doesn't render these at all today — easy to conflate with the "plugin action modal" concept since both are called "actions," but they're different mechanisms serving different purposes.
Design
What this document is, and isn't
Everything below describes the definition the server generates for a configuration or action's type — labels, element kinds, layout, validation rules, conditions. It never carries any actual configuration values. Values are a completely separate document, fetched from a separate endpoint (
GET Configuration/{id}for a configuration's current values), and that separation is structural, not incidental: a definition is generated once per type, before any specific document's array lengths or dictionary keys exist — it has no way to describe this particular user's saved data, only the shape any such document of this type would follow. Where an example below elides fields for brevity, it uses a// … fields omittedcomment, never a placeholder property — nothing in this document ever holds a real value.Element type renderers
The
UiDefinitiondocument uses a 14-member element-kind discriminator, with noautomember — the server always resolves authored intent down to one concrete kind before the document is served, so the client dispatches on that kind directly rather than inferring a render type from the underlying JSON type. The layout alist,recordorselectrenders with is held to the same standard: it is always stated, neverauto. Build order is still foundational-first:section-containerunblocks the complex variants oflist/record, so it goes first even though it isn't independently visible in the UI.What this actually looks like on the wire. This example is an illustration trimmed from the real
Core Settingspayload of a running server (6.0.0-dev.531), checked field by field against it. It is drawn as if the AniDB area were the root: in the real payload that container isRoot.Items.AniDb(section typeminimal, with seven floating sections:Login,Download,Update,URLs,HTTP,UDP,AVDump), the root is atabcontainer whoseStructurelistsImage,Import,AniDb,Metadata, … as items, andStorageStateis shown here only to illustrate anenum: in the real payload it sits one level further down, inAniDb.MyList.Rendering a container is: walk its
Structure; for aitem/actionentry, look the name up inItems/Actions(recursing the same way for a nestedsection-container); for afloating-sectionentry, look the name up inFloatingSectionsand recurse into its ownStructure/StartActions/EndActionsthe same way. A member's position in the layout is entirely decided by which of these an entry names — nothing is filtered or grouped client-side, and eachKindmaps 1:1 to a renderer so dispatch is a plain switch.That example doesn't cover
list,record, orselect. The following come from the PR author's test plugin (https://github.com/revam/dotnet-shoko-plugin-genui-test, one configuration per level of reactivity, each member documented with what a tester should see), loaded on the same running server. The element shapes below match itsGET …/UiDefinitionoutput, and the values are whatGET Configuration/{id}returned. The server writes nulls, so a select option without a group carries"GroupID": nullrather than omitting it. Base fields every element carries (Size,Visibility,Badge,RequiresRestart,EnvironmentVariable,ReactsToLiveEdit,HasOptions,Default,IsRequired,IsNullable,DeniedValues,Description) are omitted for brevity.The numbered build order below matches the examples above:
section-container— walkStructure, dispatching each entry toItems,Actions, orFloatingSectionsas described above; aFloatingSectionsentry is itself a small container with its ownStructure/pinned actions, so the same recursive component handles both levels. Recursing into a nestedsection-container(e.g. a nested configuration object) is the same component the top level already uses, just called one level deeper. A container whose section type ischeckboxalso carriesToggleMember: the key, in itsItems, of the boolean that turns the section on, to be drawn as the section's own checkbox rather than as a field inside it.list— its layout is one of:flat(an editable array — one field per index, add/remove/reorder controls per the container's own hide-add/hide-remove/sortable flags),enum-checkbox(a checkbox per member, when the item kind isenum), orcomplex-dropdown/complex-tab/complex-inline(each item is a full nestedsection-container, selected via dropdown / tabs / stacked inline respectively). The server always states one: it resolves an unspecified layout before serving — a list whose item is a class of its own comes outcomplex-inline, anything elseflat— and never overrides one the author chose, so a list of enums isflatunless the author asked forenum-checkbox, which makes the shared checkbox component below opt-in rather than inferred. Each row is labelled by readingItemTitlePath(and, as a line beneath it,ItemCategoryPath) relative to that row's own value; both arenullwhen the item has nothing to label itself with. A class says what it calls itself by holding a small title component (aTitleand an optionalSubTitle); where it says nothing, the server falls back to the member marked as its key. In every case, the item's own element description is a real, always-present child node — no inferring the item's kind from a separate, possibly-stale field. A list whoseItemhas options (see "Server-listed options") offers those choices for each entry. A[Flags]enum renders as exactly this kind of list. Real examples:OfflineImporter.AutoMatchRules/ParseRules(complex),CoreSettings.Import.VideoExtensions/Image.ImageTemplateUrls(simple scalar).record— same shape aslistbut keyed by a string/enum key instead of an index, and carries two child nodes instead of one: one describing the key, one describing the value — named so a renderer can treat a list entry's payload and a record entry's payload identically. A record's layout isflat,complex-dropdownorcomplex-tab, resolved the way a list's is (a class-valued record comes outcomplex-dropdown, anything elseflat). Its entries are labelled the way a list's rows are —ItemTitlePathandItemCategoryPathsit on the record element too, relative to a value — and an entry with neither is labelled by the key it's stored under. Each side is described from its own type: a record keyed by one enum with another enum as its value describes each side with its own members (checked with the server's definition builder on such a type, not on a running server). Either child can have server-listed options: a dictionary keyed by folder name, say, has aKeyItemthat lists the folders.enum— a fixed list of named values, each carrying its own display title, optional description, wire value, and the alias names/values that collapsed onto it as arrays (so a value stays findable by every name it's known by, e.g. a legacy alias for a renamed option — empty when it has none, never a single joined string). There is no flag on the element for combinable bit flags any more: a[Flags]enum is described as a unique list whose item is anenumof its single-bit members, so it takes the same path as any other list of enums (checkbox layout when the author asked forenum-checkbox, plain rows otherwise) and no separate renderer. Its value is an array of member names,[]for none; a member that combines several bits is never listed or written. This has been checked for a flags enum used directly as a member of a section; a flags enum nested inside a list or record was not found to follow the same description (its entries came out listing the zero and combined members and not marked unique), so a renderer should not rely on the rule there, and a plugin author who needs one can wrap it in a class. The previous text/number forms are still accepted by the server on input but are not what the schema describes, so a client should only ever send the array. If anenumelement also has server-listed options, the options narrow it: offer only the values returned, labelled the way the static list labels them. Real examples:OfflineImporter.Mode,OfflineImporter.MatchType,CoreSettings.AniDb.MyList.StorageState.select— a selection component whose options are supplied by the server as part of the configuration value itself, not as part of the element's own description — the definition only carries the layout and whether multiple selection is allowed. The layout is always stated:flat-listwhen one choice is allowed,checkbox-listwhen more than one is, unless the author chose otherwise.flat-list→ existing select component;checkbox-list→ the same shared checkbox-list sub-component asenum/list's checkbox variants. If a compact multi-select for long option lists is needed later, a tag-style control (selected values as removable chips, options searchable in a dropdown) is the preferred shape; it would need its own layout value from the server and is not part of this RFC. There's deliberately no field on the definition naming what a select depends on — instead, the element's ownReactsToLiveEdit(see Reactivity below) says which events should trigger a re-fetch of its populated options, and the recommended authoring pattern keeps the selected choice in its own scalar property with a nullable options payload populated on load/live-edit and cleared once a choice is made (or stripped in a save handler) — so a probed-but-unselected option list never gets persisted into the saved configuration document. The value this element points at (see the example above) is cased like the rest of the document —Options,Groups, and each option'sValue/Label/IsSelected/IsDefault/IsDisabled/GroupID— so a generic "read the value at this element's key" binding needs no special case for it. Aselectcarries no denied values of its own: its options live in the value, and the server files no denied values on it.selectand server-listed options are separate mechanisms: aselectcarries its options inside the value it binds to, whereas an element withHasOptionshas them fetched on demand. Real example:OfflineImporter.AutoMatchRule.LocationRules[].ManagedFolderSelector.password— the input itself is trivial: a masked single-line text input, no new component needed. Produced by either of two server-side markers on the underlying property, so no client-side distinction is needed between them. The value it binds to is another matter — see "Masked secrets" below. Real example:CoreSettings.AniDb.Password.text-area— needs one small new component (no multiline text input exists in the input library today) — a native<textarea>styled to match the existing single-line input. Real example:OfflineImporter.CustomParseRule.Regex.boolean/integer/float/string— what today's code handles as a singleautocase branching on JSON type becomes four concrete, independently-dispatched kinds. Functionally equivalent to what's already implemented for these (checkbox / number input / number input / text input) —integerandfloatboth carry their own min/max bounds directly on the element (aslong?/double?respectively), so neither needs a schema round-trip for validation range — just reached by matching the element kind directly rather than by reading the underlying schema type, which removes a layer of inference the current code needs and the new document doesn't.code-editor(the new name for what'scode-blocktoday) — same Monaco-based editor already implemented, with the language to highlight and whether to auto-format on load carried directly on this element instead of a general-purpose flag.reference— new: a pointer into a top-level table of hoisted element definitions, emitted wherever the element tree would otherwise recurse into itself (e.g. a self-referential structure). Needs a resolution step with no equivalent in the current renderer: look the pointed-to key up in that table and render it in place.unknown— new: the document's own "the server couldn't classify this" placeholder, carrying the raw schema type it failed to map. Replaces today's generic "Unsupported element type" fallback with one that reports what specifically wasn't understood.Masked secrets
The server never sends a stored secret back out. In every outward-facing configuration read — including the value a
passwordelement is bound to — it replaces each secret that is set with a placeholder (currently***SECRET-UNCHANGED:<fingerprint>***, the fingerprint being a keyed hash specific to that install), and on validate, save, action and live-edit requests it swaps a placeholder back for the stored secret before anything reads the document. The same two markers ([PasswordPropertyText]and[DataType(DataType.Password)]) that produce apasswordelement are the ones that mark a secret, so apasswordelement is where a placeholder shows up; the definition carries no separate marker beyond thepasswordkind. A secret that isnullor empty goes out as it is, so "nothing set" still looks different from "withheld": on a read, a non-empty value in apasswordelement means a secret is stored, and a client that only wants to show that never needs to recognize the placeholder.What that asks of the renderer:
nullor empty string clears the stored secret; leaving the property out of a document leaves it alone.Server-listed options
An element that lets the user pick among values only the server can enumerate carries
HasOptions: true. It sits on the element that renders the choice: a scalar member, a list'sItem, or a dictionary'sKeyItem(the keys) orItem(the values). No new element kind is involved: a dictionary whose keys and values are both listed is an ordinaryrecordwhoseKeyItemandItemboth have options.An example from the server's own documentation. The server-side documentation for this feature authors the following (a configuration with a library choice, a list of extra libraries, and a dictionary from folder name to mode;
TestModeis an enum). Real captured output from a running server follows it.What the definition then says (a
list, arecordand a scalar member, as the test plugin's equivalents come back from a running server; the field names andHasOptionsplacement are the point):And the shape of an answer, from the server's own test for a labelled provider (
Tags, whose provider returns one option with a label and one without); real answers follow below:Captured from a running server. These come from a server running
6.0.0-dev.531, built after the pull request merged, with the PR author's test plugin (https://github.com/revam/dotnet-shoko-plugin-genui-test) loaded. They are unmodified response bodies.The core
Pluginssettings are the first in-tree user of the attribute.EnabledPluginsis a dictionary from plugin name to a boolean, and only its keys have options;Priorityis a list of plugin names whose entries have options:The test plugin's dictionaries show each path form against real answers:
Errors come back as a
400validation problem keyed by member path, and three are worth knowing. A list's own path takes no options (Options.Rowsanswers that its values "take no options"; ask forRows[0].Choice). A dictionary entry whose key is not valid for the key type is rejected (TagsByMode["x"]:"x" is not a valid TestMode key; a valid key such as["Slow"]answers). A provider can refuse the draft, and the error may be keyed to a different member than the one asked about (RemoteLibrarywith no API key answers with an error onOptions.ApiKey: "Set an API key to list the remote libraries.").The answers above were requested without a body, so they reflect the saved configuration. Requests carrying an edited draft are shown under "What the responses look like".
Fetching. One route per form: the client appends
/Optionsto the form's own route —/api/v3/Configuration/{configID}/Optionsfor a configuration, or the route it invokes an executable action on with/Optionsappended, such as/api/v3/Series/1234/Action/{actionID}/Options(the series, episode, group or file is set on the action for the options provider to use). It is aPOST; the body is optional and is the current draft (the configuration being edited, or the parameters entered so far for an action) — when it is left out the saved configuration is used — andpathnames the field the options are for. Send the draft whenever the user has unsaved edits, since a provider can depend on them:pathLibraryId,Nested.ModeExtraLibraryIdsRows[2].NameRows, is not accepted when only its entries' fields have optionsModeByFolderModeByFolder["Anime"]The key in the last row need not be saved yet: for a new entry the client picks the key first, then asks for that key's values.
Answer. An array of
{ "Value": …, "Label": "…" }in the order the provider returned it.Valueis serialised the way the member itself is (so text for aGuid, an array entry for a flag enum), andLabelis always set: the provider's own label, else the enum member's name as the form already shows it, else the value as text. An empty array means there is nothing to choose right now. A refusal comes back as the usual validation problem keyed by member path (for example, "set an API key first").What a renderer does with it:
Guidshould show names rather than identifiers. Fall back to the raw value when it is not listed, such as a plugin that has since been uninstalled.Option values can be primitives, enums, the usual text round-trip types (
Guid, dates,TimeSpan,Uri) and any type that parses from text.Cross-cutting behaviors
Every element — regardless of kind — carries the same set of cross-cutting fields, so these are handled once, generically, rather than per element type:
normal/small/large/full.smalluses a compact input in place of the normal one for string/number fields;large/fullwiden the field's container instead of swapping components.visible/hidden/read-only), anadvancedflag (hidden behind a section-level "Show advanced" toggle), and two independent reactive conditions: a disable condition, and a visibility-switch condition that additionally carries which visibility state to switch to while it holds (so it isn't limited to toggling hidden/visible — it can switch to read-only too). A condition compares a dotted path (which may descend into a nested object's own members, but can never climb out of its enclosing object and can never point through a list or record index) against a value, or a list of values, using one of: equals (the default), not-equals, is-empty, is-not-empty, in, not-in, greater-than, less-than, contains. Resolution is per-instance when the condition sits inside a list/record item, always evaluates against the client's current unsaved draft rather than the last-saved document, and an operator the client doesn't recognize should be treated as unmet. Deliberately out of scope: combining more than one condition with AND/OR — every case identified so far is covered by the operator list above, and supporting real composition would need a bigger authoring-model change (conditions can't nest as currently authored) than the cases justify.smallsizing leaves no room for a subtitle. The server-side generator trims and unwraps this text at the source (no leftover documentation-comment whitespace or mid-sentence line wraps), so the client needs no defensive trimming. Descriptions may also contain Markdown — the server converts XML-doc tags to it and keeps blank lines as paragraph breaks — so render them as Markdown rather than as plain text.selectcarries none. In practiceenumfiltering is the only case — anenum's options are part of the definition itself, so filtering them is a direct list operation — and nothing else hands the client values to filter. A flag enum denies individual entries only.Also newly available on every element, worth designing the renderer to lean on rather than cross-referencing the JSON Schema separately: a pre-resolved label, an optional longer description, a default value (what a freshly constructed instance holds, or a declared default;
nullfor a nested class, a password, or no value), and whether the element is required/nullable. A client built against this document shouldn't need to consult the JSON Schema for anything except the server-side-authoritative validation pass.Config-level action buttons
A configuration's form can already declare its own action buttons (e.g. "Test Connection"), each identified by a
Namesent back to the server asactionNameon invocation. An action renders wherever it's named, not according to a position field on the action itself: a pinned button appears in its container's or floating section's ownStartActions/EndActionslist, and an inline one is named by the structure. An action cannot be attached to a single field's row. A client never filters by a position value — placement is entirely a function of which list contains the action's name. Beyond that, each action carries a title/description/theme/size, an optional icon, a condition controlling whether it's shown (with the same visibility-switch shape described above) and a separate one controlling whether it's disabled, and a flag to disable the button while the configuration is unmodified.On click: submit the current in-memory configuration to the server along with the action's
Name(asactionName) and the field/section path it's attached to. There's no resolved path on the action's own definition — a definition describes a type, not a specific instance, and the same container can repeat as a list item or a record value where the path differs per row; only the client, while rendering, knows which row it's currently looking at. So the client buildspathfrom the container chain it descended through to reach this action, the same way it already has to track which row of a list or record it's rendering — this is deliberate, not a gap to fill later. The response can carry a partial or full configuration to merge into local state (delivered as an RFC 6902 JSON Patch against what was submitted, so a removal is expressed unambiguously rather than inferred from an absent key), messages to toast, a location to navigate to, and validation errors to surface inline — respecting a flag for whether to keep any validation errors already showing.Reactivity (proactive live-edit calls)
Beyond an on-submit or on-click action, a configuration can declare a live-edit handler that computes something in response to an edit before the form is saved (e.g. deriving one field's value from another). Calling that handler on every keystroke, for a large configuration, would mean posting the entire document dozens of times a second — so the definition carries enough signal to call back only when it's actually worth it:
HasLiveEdit(this class itself handles live edits) andHasNestedLiveEdit(something below it does, even if this level doesn't); a floating section carries its ownHasNestedLiveEditthe same way, so one tab can be reactive while its siblings aren't.ReactsToLiveEdit: the specific events (edited,focused,unfocused,view-changed,new-value,clicked, orall, written as those lowercase, hyphenated strings) that make an edit to that element worth sending a request for. Empty means nothing is listening, and the form stays purely local for that field.A client should only call the live-edit endpoint for a field whose
ReactsToLiveEditis non-empty, on the events it names — turning what would otherwise be "post the whole form on every keystroke, or never call this at all" into a small, targeted set of requests. Two details of what happens once a request is sent. Every handler on the edited path runs, innermost first, so a class coordinating what its nested types do hears about an edit to one of them; each handler is handed what the previous one returned, their messages are collected in the order they were produced, their validation errors are merged by key, and the response is still a single patch. And a live edit that names a path which doesn't resolve is a no-op — an empty result, not an error — which is what a list row still being composed client-side looks like, so reactive requests can safely be treated as best-effort. An explicitly invoked action is stricter: pressing a button on something that isn't there is still an error.No ordering guarantee between parallel live-edit requests. A monotonic revision counter on the live-edit/action-result response was proposed so that parallel requests from the UI, arriving at the server in a different order than they were sent, could be told apart. It has been decided not to build it: if out-of-order requests turn out to be a real problem in practice, they will be dealt with then.
What the responses look like
Captured from a running server (
6.0.0-dev.531with the PR author's test plugin loaded). Live-edit, action and validate calls all answer with the same envelope, and every field is always present:op,pathandvalue(a JSON Pointer such as/Nested/Echo). A nested handler's patch comes back in the same list (Nested.Valueedited →/LastEventand/Nested/Echo).PatchOperationsisnullwhen the handler returned nothing to change and may be an empty array when a handler ran and changed nothing; treat both as "no patch".new-valuefor a row with nothing to say, and a path that does not resolve (Rows[5].ID) all answered200with the empty envelope above, not an error.POST …/PerformAction?actionName=<Name>&path=) answers in the same shape. Seen: a patch (FlipAction→/IsAdvancedand/Kind);"ValidationErrors": { "Required": ["Set by the \"Refuse\" action."] }keyed by member path;"ShowSaveMessage": true;"Refresh": true(reload the form);"Redirect": { "Location": "https://…", "OpenInNewTab": true }; andMessagesas a list of{ "Title"?, "Message", "Theme" }, whereTitleis absent on an untitled message and several can arrive at once.POST …/Validate, body: the draft) answersValidationErrorskeyed by member path when a rule refuses a value ({ "ValidatedText": ["…"], "Count": ["…"] }) andnullwhen it accepts.ApiKeyset in the posted draft,Options.RemoteLibraryanswered with two libraries instead of the "set an API key" refusal; withHideFastset, a mode list lost itsFastentry.Action-parameter modal
Distinct from the config-level action buttons above: invoking a plugin-registered action (of any scope) can now request its parameter form the same way a configuration requests its own — the same document shape describes both, so no special-casing is needed to render one versus the other. An action's parameter set doesn't vary by what it's invoked against, so one lookup covers a globally-scoped action and a series/episode/file/group-scoped one alike. A listing of available actions should be checked for whether an action takes parameters at all before offering to open this modal, to avoid an unnecessary round-trip for the (currently more common) parameterless case.
A parameter can have server-listed options too, fetched from the action's own route with
/Optionsappended, with the parameters entered so far as the body (see "Server-listed options").Invocation accepts an optional parameter body — omitting it still invokes exactly as a parameterless action always has, so this is additive to existing invocation, not breaking. A body that fails validation should surface as inline field errors on the modal, the same treatment config-level action buttons above already need for their own validation-error responses.
What invocation answers, captured from a running server with the test plugin's actions (
POST /api/v3/Action/{actionID}for a global action,POST /api/v3/Series/{seriesID}/Action/{actionID}for a scoped one):200, empty body (the action is accepted; there is no result to show)400,{ "errors": { "Message": ["Property Required"] }, … }400,{ "errors": { "Message": ["String Too Short"] }, … }400,{ "errors": { "Mode": ["Not In Enumeration"] }, … }400with a plain-text reason as the body (Refused on request.), not a validation problem and with no member path404, plain text (Series not found.)200Two consequences for the modal. The
400validation problem is the usual ASP.NET shape, keyed by member name, but the messages are the schema validator's own terse keywords (Property Required,String Too Short,Not In Enumeration), so a client that wants readable text should map them itself, using the constraints it already has from the definition. And a refusal from the action's own code arrives as plain text, so the modal needs a place to show a message that belongs to no field; check the content type rather than assuming JSON.Bulk invocation is out of scope for the first implementation. The server has bulk-invocation endpoints for scoped actions (
{actionID}/Group/Bulk,/Series/Bulk,/Episode/Bulk,/File/Bulk) that apply one set of invocation parameters across several entities at once, rejecting the whole call if any entity ID doesn't resolve. The PR's author has said the WebUI does not need them for a first implementation: they were added to the API so the WebUI can adopt them later without a server upgrade, for example after a stable server release. This RFC therefore doesn't design a bulk flow. For whoever picks it up: the bulk body is the list of IDs plus optional parameters limited to booleans, numbers and string lists (no nested objects), and the bulk routes have no/Optionsroute of their own, since server-listed options are fetched per entity on the single-entity routes (an action whose choices are, say, the titles of the series it runs on has nothing to list when several series are selected).All reactions