Repository navigation
Conversation
Can't give feedback when the DTO shape is not displayed anywhere. For other reviewers' reference, here's the current shape ( {
"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 |
|
Proposal: group The problemRendering 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 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 Proposed shapeKeep {
"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 tooThe shape is identical regardless of
Section ordering would be first-appearance order of authored members, with floating/default sections placed first or last per What this buys
Payload is roughly a wash: the section name moves off the elements into the section entries rather than being duplicated. |
|
Follow-up: renderer-perspective findings on actions, descriptions, and paths Tested the definition against a live 1. Actions: add the invocation
|
|
Examples are from a hypothetical The definition can't say an element is reactive
That leaves a client two options. Post the entire document on every event on every field, or public ReactiveEventType Reactive { get; set; } // 0 = not reactiveNothing can populate it, because the authoring surface has no way to say which members a [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. [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 A third: whether a reactive request is best-effort or strict. A newly added list row exists
|
|
Proposal: a merge-friendly response model for 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
the client receives
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
What the client reduces toonSuccess: (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 Scope noteThis only reshapes the LiveEdit (and optionally action-result) response envelope — the request flow, reactive handler model, and |
I like this shape more than the object that we would have to loop over and build this shape internally anyways |
553db75 to
d988145
Compare
|
Pushed, rebased onto 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. Grouping is compiled server-side now, but positionally rather than by sweeping loose members into a default section. A container has
Pinned buttons hoist out of the flow into A gathered section can also carry a description now, declared once on the class with On the renderer findings
On reactivity
One source break worth flagging: dropping On the LiveEdit response envelope The premise is off by one layer. 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 Worth noting that the reactivity work above shrinks the problem too: with 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: |
d988145 to
f126143
Compare
|
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.
A row can say what it is called. Denied values are filed on the element that can act on them. 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 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. |
5fc112f to
c351a04
Compare
dacef55 to
0669a1b
Compare
… 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
`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
|



No longer a draft.
Breaking for the WebUI, and going out on
master.x-uiDefinitionis cut downto 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 includesTMDB, whose settings left
ServerSettingsfor a plugin configuration of their own(
tmdb.json,TmdbConfiguration), and the WebAOM renamer, now a bundled plugin.Both are served through the same
UiDefinitionroute 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-uiDefinitionextension bag for presentation. JSON Schema is a validation vocabulary, so
presentation has to be inferred from it —
elementType: "auto"is literally aninstruction to go and read
schema.type. References are unresolved, so the clientdereferences
$refitself while carefully preserving the UI metadata astandards-compliant dereferencer would discard.
Executable actions have it worse.
ActionServicecan deserialise a parameterpayload 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 forrendering. An abstract
UiElementwith a concreteKinddiscriminator and 15sealed element types;
$refs resolved; labels pre-resolved; constraints,nullability and requiredness on the element that needs them.
autonever appearson the wire.
A container files its members in three maps,
Items,ActionsandFloatingSections, and puts them back in order withStructure, whose entriescarry a
Kindofitem,actionorfloating-sectionnaming 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 atthe 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.
AppendFloatingSectionsAtEndmoves the gathered sections pastthe rest.
[FloatingSection("Login", Description = "…")]on the class describes agathered 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
Positionlands inStartActionsorEndActionson whatever holdsit. A client never filters by position, and
Positionitself does not reach thewire.
GET /api/v3/Configuration/{configID}/UiDefinitionGET /api/v3/Action/{actionID}/UiDefinitionPOST /api/v3/Action/{actionID}(and the four scoped variants)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 resolverrather 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,nullfor 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]orrequiredmust be sent; any other omittedparameter 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 byproperty 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 existingGenerateSchema(Type), with noIConfigurationconstraint, so a plugin candescribe any type it owns.
ConfigurationInfo.UiDefinitionis a lazy cache over it.Shoko.BuildTools.Analyzers— a Roslyn analyzer, packed underanalyzers/dotnet/cs/, turning configuration and action shapes the generatorcannot render into compiler errors. Sixteen rules,
SHOKO0001toSHOKO0016,covering every
throwinShokoJsonSchemaGeneratorplus one silent metadata lossthat never threw, each explained with a bad example and its fix in
Shoko.BuildTools.Analyzers/README.md. It walks the property graph fromIConfigurationandIExecutableActionroots, so an identical shape elsewhere in the codebase isuntouched. The runtime checks remain — a plugin can be built without referencing
the package.
What this changes
The schema stays the validation contract, but
x-uiDefinitionshrinks to whatthe validator reads. The bag existed to carry presentation;
UiDefinitiondoesthat now. What remains is the five entries
ShokoJsonSchemaValidatorreads backduring validation —
elementType,requiresRestart,envVar,envVarOverridableandenumDefinitions(itself down tovalueandaliasValues) — and a property carrying none of them gets no bag at all. Theclass-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 testpins 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.
DisplayButtonPositionloses its aliases.Top/Leftshared a value withStart, andBottom/RightwithEnd, while[EnumMember]sat only on thecanonical members — so which name the runtime resolved for a duplicate was
unspecified, and every alias lost its attribute.
Position = Topwas serialisingas
"Left". NowAuto/Start/Endonly, 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
mastermodelsx-uiDefinitionwithout anactionsmember at all.The shared authoring surface moves to
Shoko.Abstractions.UI— presentationalattributes, the
Display*enums,SelectComponent<>.Config.Enumswas alreadybeing imported by the UI namespace, so the dependency pointed the wrong way.
Configuration-lifecycle attributes (
StorageLocation,ConfigurationAction,HideDefaultSaveAction,RequiresRestart,EnvironmentVariable) stay inConfig. Source-breaking.CodeEditorAttribute.Heightis gone. Nothing read it, and an element's sizeis
[Visibility(Size = …)]like any other.CodeEditorLanguageserialises inkebab-case like its sibling enums (
plain-text,csharp,json).A select's value keeps its members' declared names.
SelectComponent<>,SelectOption<>andSelectGroupno longer pin lowercase JSON names (options,selected,groupId, …), so their value is written like every other property inthe 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.
UiConditionwas one path, one value andone equality, with an
InverseConditionflag standing in for every comparison itcould not make.
UiConditionOperatoraddsNotEquals,IsEmpty,IsNotEmpty,In,NotIn,GreaterThan,LessThanandContains, withEqualsas thedefault, and the inverse flag is gone from both attributes and the wire. A cleared
text field holds
"", whichIsEmptycatches and an equality test againstnullnever did. A condition path may descend into a nested class, spelled
$"{nameof(Nested)}.{nameof(NestedConfiguration.Mode)}", and may not point througha list or dictionary, which carries no index.
A live-edit handler can say what it reacts to.
ConfigurationInfo.HasLiveEditwas one bool for a whole configuration, so a client either posted the entire
document on every event on every field or never called
LiveEditat all. Acontainer now carries
HasLiveEditandHasNestedLiveEdit, each floating sectioncarries
HasNestedLiveEdit, and the hook itself narrows what it wants:which reaches the element as
ReactsToLiveEdit, the events an edit to it is worthsending on.
Eventsreplaces the singularReactiveEventType: a request carriesone 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.PreviousResultand as aninjectable 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
TitleComponentlabels itsrows with it: a list or a record points
ItemTitlePathandItemCategoryPathatits
TitleandSubTitle, 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,
Flatbeing the new name fora plain row, so
autonever reaches a client here either.Two more analyzer rules.
SHOKO0006for a condition that could never hold, andSHOKO0007for a handler watching a member that is not there, a name pointingthrough 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.IDisName. A configuration action is identified by the name ofthe method behind it and goes back as
actionName, while an executable action isidentified by a
Guid. Both sat underID.Descriptions are unwrapped and trimmed. A summary is indented and line-wrapped
for whoever reads the source, and both reached the client verbatim.
ServerSettingsalone 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 methodturns the members it names into choices the server enumerates on request.
T[]or anyIEnumerable<T>, orSelectOption<T>of it tolabel each option, directly or through
Task/ValueTask. Its parameters arefilled 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.
Targetpicks the part:Values(the default) is a scalar, a list's entries or adictionary's values;
Keysis a dictionary's keys. A dictionary's keys and valuesmay have different providers, and one provider may serve several members of the
same option type.
Tis a primitive, an enum, one of the BCL types that round-trip through text, orany type, a plugin's own included, implementing
IParsable<TSelf>or carrying a[TypeConverter]to and from a string or primitive. The provider'sTmust matchthe member's option type exactly; conversions never bridge them.
HasOptions. A client POSTs to its form's own route plus/Options: the configuration's/api/v3/Configuration/{configID}, or the routeit 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
pathnames the field getting the options:Library,Tagsfor a list's entries,Mappings[2].Folderfor a field in alist entry,
ModeByPluginfor a dictionary's keys, andModeByPlugin["<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 clientpicks a new entry's key first.
{ Value, Label }in the order returned.Labelis alwaysset: 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
Guidkey by its plugin's name, say) and fall back to the rawvalue. There is no server-side search: providers return the full current list.
GenericValidationException, answered as a validationproblem keyed as thrown; anything else is a server error.
any shape. A provider that does not fit fails startup, and
SHOKO0008toSHOKO0013andSHOKO0015(an[OptionsKey]parameter that does not fit)report the same at compile time, one rule per kind of mistake.
PluginSettings(Plugins.EnabledPluginskeys andPlugins.Priorityentries) 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 = …)],AttachedStartActionsandAttachedEndActionswere removed. Configuration-levelcustom 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 anobject; it is now a string element, written with its invariant
ToString()andread with
TryParsein configurations and action parameters, so no converter isneeded. A type wanting another wire shape declares
[JsonConverter]or[TypeConverter].SelectOption<T>no longer requiresIEquatable<T>, so enums can carry labels.A flag enum is a list of its members. A
[Flags]member is described andrendered exactly like a
List<TEnum>, so a client treats it asenum[]and needsno 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 theinteger are still accepted on read for backwards compatibility, but the schema
only describes the array. Combined members such as
All = A | Bstay on theserver: 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.DropFolderTypeand/api/v3/Settingsare unchanged, which leavesAniDb.MyList.FetchModeandSyncTargetsas arrays in the configuration routesand numbers in the settings route.
ServerSettingsholds those two, sosettings- server.jsonnow 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:
ReactiveEventTypeis written by its kebab-case name (ReactsToLiveEditwasnumbers), and
LiveEditbinds those names (view-changed,new-value).Validatehook first, also behind acustom
Savehook.property initialisers count, not only
[DefaultValue]. An executable action'sparameter defaults ride in its definition.
parameter no longer blocks it.
[Section(DisplaySectionType.Checkbox, ToggleMember = nameof(Enabled))], exposed asToggleMemberand checked bySHOKO0016.Defects this closes
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.
List<List<T>>andDictionary<K,Dictionary<K,V>>collide on a single propertykey and the outer level is silently dropped;
List<Dictionary<K,V>>throws witha 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.
property key, so the value element came back as an enum carrying the key's
members, and the key itself rendered as free text.
lookup asked for a
LiveEdithandler whatever hook was being run, so a classdeclaring
Load,New,SaveorValidatehad its live-edit handler run inits place, or resolved nothing at all.
GET /api/v3/Configuration/{configID}answered 409 for a
Loadhandler, and a customSavehandler silentlypersisted nothing. Present on
masteras well; nothing published hits it.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.
action.
PerformActionlooked the method up by name alone. Found here and fixedon
masterseparately.Measurements
ServerSettings, minified, measured on the rebased branch against currentmaster(a33696c1b). Onmasterthe schema carries the presentation bag andcomes 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.EnabledPluginsandPlugins.Priority, the only elementssetting
HasOptions.Against the current renderer (
src/components/Dynamic/, 275 lines plus a 75-line$refhelper): the helper becomes unnecessary in full, andDynamicField'sautobranch, roughly 45 of its 134 lines, hand-dispatching on
propertySchema.typeandreading
minimum/maximumoff the schema node, collapses to a switch onKind.Settled in review
Added in the second round.
revisionon theLiveEditresponse is not added. Alive 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.