diff --git a/.gitignore b/.gitignore
index 18865018aa..04525f7dc0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -185,4 +185,7 @@ UpgradeLog*.htm
__pycache__
# OpenCode
-opencode.json
\ No newline at end of file
+opencode.json
+
+# Generated by the UI-definition tests for inspection; not an artefact of the build.
+poc-output/
diff --git a/Shoko.Abstractions/Actions/ExecutableActionInfo.cs b/Shoko.Abstractions/Actions/ExecutableActionInfo.cs
index 1892ed19ad..4393e5afbf 100644
--- a/Shoko.Abstractions/Actions/ExecutableActionInfo.cs
+++ b/Shoko.Abstractions/Actions/ExecutableActionInfo.cs
@@ -1,4 +1,5 @@
using System;
+using Shoko.Abstractions.UI;
namespace Shoko.Abstractions.Actions;
@@ -40,6 +41,27 @@ namespace Shoko.Abstractions.Actions;
///
/// The ID of the plugin that owns the action.
///
+///
+/// A render-ready description of the action's invocation parameters, or
+/// when the action declares none.
+///
+///
+///
+/// An action's parameters are simply its own settable, serialized
+/// properties — the caller's payload is populated straight onto the action
+/// instance — so they are described by the very same
+/// a configuration is described by. A client
+/// renders an invocation form from it exactly as it renders a
+/// configuration editor.
+///
+///
+/// The action's own metadata surface (,
+/// , ,
+/// , ,
+/// and the scoped context) is deliberately not part of
+/// it; those are described by this record instead.
+///
+///
public sealed record ExecutableActionInfo(
Guid Id,
string Name,
@@ -50,5 +72,6 @@ public sealed record ExecutableActionInfo(
ActionPermission Permission,
bool RequiresConfirmation,
string? ConfirmationMessage,
- Guid PluginId
+ Guid PluginId,
+ UiDefinition? Parameters = null
);
diff --git a/Shoko.Abstractions/Config/ConfigurationActionMessage.cs b/Shoko.Abstractions/Config/ConfigurationActionMessage.cs
index 8b50b561a5..0ca96b7cb6 100644
--- a/Shoko.Abstractions/Config/ConfigurationActionMessage.cs
+++ b/Shoko.Abstractions/Config/ConfigurationActionMessage.cs
@@ -1,4 +1,4 @@
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Abstractions.Config;
diff --git a/Shoko.Abstractions/Config/ConfigurationActionResult.cs b/Shoko.Abstractions/Config/ConfigurationActionResult.cs
index c74644f169..6b86d374a1 100644
--- a/Shoko.Abstractions/Config/ConfigurationActionResult.cs
+++ b/Shoko.Abstractions/Config/ConfigurationActionResult.cs
@@ -1,5 +1,5 @@
using System.Collections.Generic;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Abstractions.Config;
diff --git a/Shoko.Abstractions/Config/ConfigurationInfo.cs b/Shoko.Abstractions/Config/ConfigurationInfo.cs
index 44b9d9b3b8..fb8f8d6e10 100644
--- a/Shoko.Abstractions/Config/ConfigurationInfo.cs
+++ b/Shoko.Abstractions/Config/ConfigurationInfo.cs
@@ -4,6 +4,7 @@
using NJsonSchema;
using Shoko.Abstractions.Config.Services;
using Shoko.Abstractions.Plugin.Models;
+using Shoko.Abstractions.UI;
namespace Shoko.Abstractions.Config;
@@ -114,6 +115,32 @@ public IReadOnlySet LoadedEnvironmentVariables
///
public required JsonSchema Schema { get; init; }
+ private UiDefinition? _uiDefinition = null;
+
+ ///
+ /// A render-ready description of how to lay out an editor for the
+ /// configuration, in the same shape an executable action's parameters are
+ /// described by.
+ ///
+ ///
+ ///
+ /// Unlike , the returned document is meant to be
+ /// sufficient on its own: every element carries a concrete element kind,
+ /// its label, its default and the constraints needed for a cheap
+ /// client-side pre-check. The schema remains the authority for server-side
+ /// validation.
+ ///
+ ///
+ /// Built on first read and held from then on. A configuration's definition
+ /// can run to a hundred kilobytes or more, so nothing pays for one until it
+ /// asks for it, and nothing pays twice. Two threads racing the first read
+ /// both build one and the later assignment wins, which costs a discarded
+ /// tree and never a wrong answer.
+ ///
+ ///
+ public UiDefinition UiDefinition
+ => _uiDefinition ??= _configurationService.GenerateUiDefinition(Type);
+
///
/// Information about the plugin that the configuration belongs to.
///
diff --git a/Shoko.Abstractions/Config/Enums/DisplayButtonPosition.cs b/Shoko.Abstractions/Config/Enums/DisplayButtonPosition.cs
deleted file mode 100644
index d0df77595c..0000000000
--- a/Shoko.Abstractions/Config/Enums/DisplayButtonPosition.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using System.Runtime.Serialization;
-
-namespace Shoko.Abstractions.Config.Enums;
-
-///
-/// Position of an element in the UI.
-///
-public enum DisplayButtonPosition
-{
- ///
- /// The element is automatically placed.
- ///
- [EnumMember(Value = "auto")]
- Auto = 0,
-
- ///
- /// The element is placed at the start at the parent component.
- ///
- [EnumMember(Value = "start")]
- Start = 1,
-
- ///
- /// Alias for Start.
- ///
- Top = Start,
-
- ///
- /// Alias for Start.
- ///
- Left = Start,
-
- ///
- /// The element is placed at the end of the parent component.
- ///
- [EnumMember(Value = "end")]
- End = 2,
-
- ///
- /// Alias for End.
- ///
- Right = End,
-
- ///
- /// Alias for End.
- ///
- Bottom = End,
-}
diff --git a/Shoko.Abstractions/Config/Services/IConfigurationService.cs b/Shoko.Abstractions/Config/Services/IConfigurationService.cs
index a80bbbe875..a6c0fe6562 100644
--- a/Shoko.Abstractions/Config/Services/IConfigurationService.cs
+++ b/Shoko.Abstractions/Config/Services/IConfigurationService.cs
@@ -5,6 +5,7 @@
using Shoko.Abstractions.Config.Events;
using Shoko.Abstractions.Config.Exceptions;
using Shoko.Abstractions.Plugin;
+using Shoko.Abstractions.UI;
using Shoko.Abstractions.User;
namespace Shoko.Abstractions.Config.Services;
@@ -519,6 +520,41 @@ public interface IConfigurationService
///
JsonSchema GenerateSchema(Type type);
+ ///
+ /// Generates a render-ready UI definition for the specified type using
+ /// the custom schema generator.
+ ///
+ ///
+ ///
+ /// Unlike , the returned document is
+ /// meant to be sufficient on its own: every element carries a concrete
+ /// element kind, its label, its default and the constraints needed for a
+ /// cheap client-side pre-check. The schema remains the authority for
+ /// server-side validation.
+ ///
+ ///
+ /// The type does not have to be a registered configuration, or an
+ /// at all — a plugin can describe any
+ /// shape it wants a form for. is derived
+ /// the same way derives
+ /// Schema.Id, so a type belonging to a loaded plugin gets a
+ /// stable id and anything else gets .
+ ///
+ ///
+ /// Nothing is cached here; each call walks the type afresh. A
+ /// configuration's own definition is cached by
+ /// , which is what most
+ /// callers should read instead.
+ ///
+ ///
+ ///
+ /// The type.
+ ///
+ ///
+ /// The UI definition.
+ ///
+ UiDefinition GenerateUiDefinition(Type type);
+
///
/// Serializes the specified configuration to JSON.
///
diff --git a/Shoko.Abstractions/Config/Attributes/BadgeAttribute.cs b/Shoko.Abstractions/UI/Attributes/BadgeAttribute.cs
similarity index 89%
rename from Shoko.Abstractions/Config/Attributes/BadgeAttribute.cs
rename to Shoko.Abstractions/UI/Attributes/BadgeAttribute.cs
index c74e502002..441d30f2a1 100644
--- a/Shoko.Abstractions/Config/Attributes/BadgeAttribute.cs
+++ b/Shoko.Abstractions/UI/Attributes/BadgeAttribute.cs
@@ -1,7 +1,7 @@
using System;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
-namespace Shoko.Abstractions.Config.Attributes;
+namespace Shoko.Abstractions.UI.Attributes;
///
/// Controls the displayed badge of a property/field in the UI.
diff --git a/Shoko.Abstractions/Config/Attributes/CodeEditorAttribute.cs b/Shoko.Abstractions/UI/Attributes/CodeEditorAttribute.cs
similarity index 81%
rename from Shoko.Abstractions/Config/Attributes/CodeEditorAttribute.cs
rename to Shoko.Abstractions/UI/Attributes/CodeEditorAttribute.cs
index 97df4303b0..76f8cf6b12 100644
--- a/Shoko.Abstractions/Config/Attributes/CodeEditorAttribute.cs
+++ b/Shoko.Abstractions/UI/Attributes/CodeEditorAttribute.cs
@@ -1,7 +1,7 @@
using System;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
-namespace Shoko.Abstractions.Config.Attributes;
+namespace Shoko.Abstractions.UI.Attributes;
///
/// Used to mark a property/field as a code editor in the specified language in
@@ -20,11 +20,6 @@ public class CodeEditorAttribute : Attribute
///
public bool AutoFormatOnLoad { get; set; }
- ///
- /// The height of the text-area in the UI.
- ///
- public DisplayElementSize Height { get; set; }
-
///
/// Initializes a new instance of the class with the specified .
///
diff --git a/Shoko.Abstractions/Config/Attributes/CustomActionAttribute.cs b/Shoko.Abstractions/UI/Attributes/CustomActionAttribute.cs
similarity index 91%
rename from Shoko.Abstractions/Config/Attributes/CustomActionAttribute.cs
rename to Shoko.Abstractions/UI/Attributes/CustomActionAttribute.cs
index 4c1dbe0e92..54cc4a72d7 100644
--- a/Shoko.Abstractions/Config/Attributes/CustomActionAttribute.cs
+++ b/Shoko.Abstractions/UI/Attributes/CustomActionAttribute.cs
@@ -1,8 +1,8 @@
using System;
using System.Diagnostics.CodeAnalysis;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
-namespace Shoko.Abstractions.Config.Attributes;
+namespace Shoko.Abstractions.UI.Attributes;
///
/// Defines a custom action for a section in the UI.
@@ -121,5 +121,11 @@ public object? DisableWhenSetTo
///
/// When set, will disable the action if no changes are made to the configuration.
///
+ ///
+ /// Configuration-only: it compares the edited document against the saved
+ /// one, and an executable action's parameter form has nothing saved to
+ /// compare against. It stays on this otherwise shared attribute rather than
+ /// splitting the attribute in two over a single property.
+ ///
public bool DisableIfNoChanges { get; set; }
}
diff --git a/Shoko.Abstractions/Config/Attributes/ListAttribute.cs b/Shoko.Abstractions/UI/Attributes/ListAttribute.cs
similarity index 94%
rename from Shoko.Abstractions/Config/Attributes/ListAttribute.cs
rename to Shoko.Abstractions/UI/Attributes/ListAttribute.cs
index 9142f7864e..baf27d5955 100644
--- a/Shoko.Abstractions/Config/Attributes/ListAttribute.cs
+++ b/Shoko.Abstractions/UI/Attributes/ListAttribute.cs
@@ -1,7 +1,7 @@
using System;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
-namespace Shoko.Abstractions.Config.Attributes;
+namespace Shoko.Abstractions.UI.Attributes;
///
/// Define extra details for a list in the UI.
diff --git a/Shoko.Abstractions/Config/Attributes/RecordAttribute.cs b/Shoko.Abstractions/UI/Attributes/RecordAttribute.cs
similarity index 91%
rename from Shoko.Abstractions/Config/Attributes/RecordAttribute.cs
rename to Shoko.Abstractions/UI/Attributes/RecordAttribute.cs
index 0f150062bb..8e469a822d 100644
--- a/Shoko.Abstractions/Config/Attributes/RecordAttribute.cs
+++ b/Shoko.Abstractions/UI/Attributes/RecordAttribute.cs
@@ -1,7 +1,7 @@
using System;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
-namespace Shoko.Abstractions.Config.Attributes;
+namespace Shoko.Abstractions.UI.Attributes;
///
/// Define extra details for a record in the UI.
diff --git a/Shoko.Abstractions/Config/Attributes/SectionAttribute.cs b/Shoko.Abstractions/UI/Attributes/SectionAttribute.cs
similarity index 72%
rename from Shoko.Abstractions/Config/Attributes/SectionAttribute.cs
rename to Shoko.Abstractions/UI/Attributes/SectionAttribute.cs
index 80abf334bf..4682c156a0 100644
--- a/Shoko.Abstractions/Config/Attributes/SectionAttribute.cs
+++ b/Shoko.Abstractions/UI/Attributes/SectionAttribute.cs
@@ -1,7 +1,7 @@
using System;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
-namespace Shoko.Abstractions.Config.Attributes;
+namespace Shoko.Abstractions.UI.Attributes;
///
/// Define extra details around a section in the UI.
@@ -24,6 +24,12 @@ public class SectionAttribute(DisplaySectionType sectionType = DisplaySectionTyp
///
/// Show the save action for the class/group in the UI.
///
+ ///
+ /// Configuration-only, and ignored on an executable action's parameter
+ /// form: an invocation has nothing to save, so the client renders an invoke
+ /// button instead. It stays on this otherwise shared attribute rather than
+ /// splitting the attribute in two over a single property.
+ ///
public bool ShowSaveAction { get; set; } = false;
///
diff --git a/Shoko.Abstractions/Config/Attributes/SectionNameAttribute.cs b/Shoko.Abstractions/UI/Attributes/SectionNameAttribute.cs
similarity index 88%
rename from Shoko.Abstractions/Config/Attributes/SectionNameAttribute.cs
rename to Shoko.Abstractions/UI/Attributes/SectionNameAttribute.cs
index 6cafd12a7e..9471725fc5 100644
--- a/Shoko.Abstractions/Config/Attributes/SectionNameAttribute.cs
+++ b/Shoko.Abstractions/UI/Attributes/SectionNameAttribute.cs
@@ -1,7 +1,7 @@
using System;
-namespace Shoko.Abstractions.Config.Attributes;
+namespace Shoko.Abstractions.UI.Attributes;
///
/// Define the name of a section in the UI.
diff --git a/Shoko.Abstractions/Config/Attributes/SelectAttribute.cs b/Shoko.Abstractions/UI/Attributes/SelectAttribute.cs
similarity index 91%
rename from Shoko.Abstractions/Config/Attributes/SelectAttribute.cs
rename to Shoko.Abstractions/UI/Attributes/SelectAttribute.cs
index b7c8b70d40..b92a4b72c7 100644
--- a/Shoko.Abstractions/Config/Attributes/SelectAttribute.cs
+++ b/Shoko.Abstractions/UI/Attributes/SelectAttribute.cs
@@ -1,7 +1,7 @@
using System;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
-namespace Shoko.Abstractions.Config.Attributes;
+namespace Shoko.Abstractions.UI.Attributes;
///
/// Define extra details for a select in the UI.
diff --git a/Shoko.Abstractions/Config/Attributes/TextAreaAttribute.cs b/Shoko.Abstractions/UI/Attributes/TextAreaAttribute.cs
similarity index 82%
rename from Shoko.Abstractions/Config/Attributes/TextAreaAttribute.cs
rename to Shoko.Abstractions/UI/Attributes/TextAreaAttribute.cs
index f103be6b1d..e744dbeffd 100644
--- a/Shoko.Abstractions/Config/Attributes/TextAreaAttribute.cs
+++ b/Shoko.Abstractions/UI/Attributes/TextAreaAttribute.cs
@@ -1,6 +1,6 @@
using System;
-namespace Shoko.Abstractions.Config.Attributes;
+namespace Shoko.Abstractions.UI.Attributes;
///
/// Used to mark a property/field as a text-area in the UI.
diff --git a/Shoko.Abstractions/Config/Attributes/VisibilityAttribute.cs b/Shoko.Abstractions/UI/Attributes/VisibilityAttribute.cs
similarity index 98%
rename from Shoko.Abstractions/Config/Attributes/VisibilityAttribute.cs
rename to Shoko.Abstractions/UI/Attributes/VisibilityAttribute.cs
index 0e30916baf..ba8c8ba868 100644
--- a/Shoko.Abstractions/Config/Attributes/VisibilityAttribute.cs
+++ b/Shoko.Abstractions/UI/Attributes/VisibilityAttribute.cs
@@ -1,8 +1,8 @@
using System;
using System.Diagnostics.CodeAnalysis;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
-namespace Shoko.Abstractions.Config.Attributes;
+namespace Shoko.Abstractions.UI.Attributes;
///
/// Controls the visibility of a property/field in the UI.
diff --git a/Shoko.Abstractions/Config/Components/SelectComponent.cs b/Shoko.Abstractions/UI/Components/SelectComponent.cs
similarity index 98%
rename from Shoko.Abstractions/Config/Components/SelectComponent.cs
rename to Shoko.Abstractions/UI/Components/SelectComponent.cs
index 5e346a644f..5386933e9a 100644
--- a/Shoko.Abstractions/Config/Components/SelectComponent.cs
+++ b/Shoko.Abstractions/UI/Components/SelectComponent.cs
@@ -7,7 +7,7 @@
using System.Text.Json.Serialization;
using Newtonsoft.Json;
-namespace Shoko.Abstractions.Config.Components;
+namespace Shoko.Abstractions.UI.Components;
///
/// A select component for the UI.
diff --git a/Shoko.Abstractions/Config/Components/SelectGroup.cs b/Shoko.Abstractions/UI/Components/SelectGroup.cs
similarity index 97%
rename from Shoko.Abstractions/Config/Components/SelectGroup.cs
rename to Shoko.Abstractions/UI/Components/SelectGroup.cs
index 7e6bf17a4c..7fe5f77dbb 100644
--- a/Shoko.Abstractions/Config/Components/SelectGroup.cs
+++ b/Shoko.Abstractions/UI/Components/SelectGroup.cs
@@ -4,7 +4,7 @@
using System.Text.Json.Serialization;
using Newtonsoft.Json;
-namespace Shoko.Abstractions.Config.Components;
+namespace Shoko.Abstractions.UI.Components;
///
/// A select group for the UI.
diff --git a/Shoko.Abstractions/Config/Components/SelectOption.cs b/Shoko.Abstractions/UI/Components/SelectOption.cs
similarity index 98%
rename from Shoko.Abstractions/Config/Components/SelectOption.cs
rename to Shoko.Abstractions/UI/Components/SelectOption.cs
index 329a596185..29a25ff185 100644
--- a/Shoko.Abstractions/Config/Components/SelectOption.cs
+++ b/Shoko.Abstractions/UI/Components/SelectOption.cs
@@ -4,7 +4,7 @@
using System.Text.Json.Serialization;
using Newtonsoft.Json;
-namespace Shoko.Abstractions.Config.Components;
+namespace Shoko.Abstractions.UI.Components;
///
/// A select option for the UI.
diff --git a/Shoko.Abstractions/UI/Elements/UiBooleanElement.cs b/Shoko.Abstractions/UI/Elements/UiBooleanElement.cs
new file mode 100644
index 0000000000..ae3436c7f2
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiBooleanElement.cs
@@ -0,0 +1,10 @@
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A boolean toggle.
+///
+public sealed class UiBooleanElement : UiElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.Boolean;
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiCodeEditorElement.cs b/Shoko.Abstractions/UI/Elements/UiCodeEditorElement.cs
new file mode 100644
index 0000000000..0b3adb5fd5
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiCodeEditorElement.cs
@@ -0,0 +1,22 @@
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A syntax-highlighted code editor.
+///
+public sealed class UiCodeEditorElement : UiTextElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.CodeEditor;
+
+ ///
+ /// The language to highlight the content as.
+ ///
+ public CodeEditorLanguage Language { get; init; }
+
+ ///
+ /// Whether the client should reformat the content when it is first loaded.
+ ///
+ public bool AutoFormatOnLoad { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiEnumElement.cs b/Shoko.Abstractions/UI/Elements/UiEnumElement.cs
new file mode 100644
index 0000000000..8328ac1775
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiEnumElement.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A choice between a fixed set of named values.
+///
+public sealed class UiEnumElement : UiElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.Enum;
+
+ ///
+ /// The selectable values, in declaration order.
+ ///
+ public IReadOnlyList Values { get; init; } = [];
+
+ ///
+ /// Whether the values are bit flags and can be combined.
+ ///
+ public bool IsFlag { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiFloatElement.cs b/Shoko.Abstractions/UI/Elements/UiFloatElement.cs
new file mode 100644
index 0000000000..9e6d91eb2c
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiFloatElement.cs
@@ -0,0 +1,20 @@
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A fractional-number input.
+///
+public sealed class UiFloatElement : UiElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.Float;
+
+ ///
+ /// The smallest accepted value, or null when unbounded.
+ ///
+ public double? Minimum { get; init; }
+
+ ///
+ /// The largest accepted value, or null when unbounded.
+ ///
+ public double? Maximum { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiIntegerElement.cs b/Shoko.Abstractions/UI/Elements/UiIntegerElement.cs
new file mode 100644
index 0000000000..300d701405
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiIntegerElement.cs
@@ -0,0 +1,20 @@
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A whole-number input.
+///
+public sealed class UiIntegerElement : UiElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.Integer;
+
+ ///
+ /// The smallest accepted value, or null when unbounded.
+ ///
+ public long? Minimum { get; init; }
+
+ ///
+ /// The largest accepted value, or null when unbounded.
+ ///
+ public long? Maximum { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiListElement.cs b/Shoko.Abstractions/UI/Elements/UiListElement.cs
new file mode 100644
index 0000000000..7679a011fd
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiListElement.cs
@@ -0,0 +1,64 @@
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// An ordered collection of items of a single element kind.
+///
+public sealed class UiListElement : UiElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.List;
+
+ ///
+ /// How the list itself should be laid out.
+ ///
+ public DisplayListType ListType { get; init; }
+
+ ///
+ /// The element used to render and validate each item.
+ ///
+ public UiElement Item { get; init; } = null!;
+
+ ///
+ /// Whether the user may reorder the items.
+ ///
+ public bool Sortable { get; init; }
+
+ ///
+ /// Whether duplicate items are rejected.
+ ///
+ public bool UniqueItems { get; init; }
+
+ ///
+ /// Whether the add-item affordance is suppressed.
+ ///
+ public bool HideAddAction { get; init; }
+
+ ///
+ /// Whether the remove-item affordance is suppressed.
+ ///
+ public bool HideRemoveAction { get; init; }
+
+ ///
+ /// The fewest items the list may hold, or null when unbounded.
+ ///
+ public int? MinItems { get; init; }
+
+ ///
+ /// The most items the list may hold, or null when unbounded.
+ ///
+ public int? MaxItems { get; init; }
+
+ ///
+ /// Dotted path, relative to an item, to the value to use as the item's
+ /// primary label, or null to stringify the item itself.
+ ///
+ public string? ItemTitlePath { get; init; }
+
+ ///
+ /// Dotted path, relative to an item, to the value to use as the item's
+ /// secondary/category label, or null when there is none.
+ ///
+ public string? ItemCategoryPath { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiPasswordElement.cs b/Shoko.Abstractions/UI/Elements/UiPasswordElement.cs
new file mode 100644
index 0000000000..8f84705a1c
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiPasswordElement.cs
@@ -0,0 +1,10 @@
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A masked text input.
+///
+public sealed class UiPasswordElement : UiTextElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.Password;
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiRecordElement.cs b/Shoko.Abstractions/UI/Elements/UiRecordElement.cs
new file mode 100644
index 0000000000..99bc22acc2
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiRecordElement.cs
@@ -0,0 +1,44 @@
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A keyed collection of values of a single element kind.
+///
+public sealed class UiRecordElement : UiElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.Record;
+
+ ///
+ /// How the record itself should be laid out.
+ ///
+ public DisplayRecordType RecordType { get; init; }
+
+ ///
+ /// The element each key is rendered and validated as.
+ ///
+ public UiElement KeyItem { get; init; } = null!;
+
+ ///
+ /// The element each value is rendered and validated as, named to match
+ /// so a renderer can treat the payload of a
+ /// list entry and of a record entry the same way.
+ ///
+ public UiElement Item { get; init; } = null!;
+
+ ///
+ /// Whether the user may reorder the entries.
+ ///
+ public bool Sortable { get; init; }
+
+ ///
+ /// Whether the add-entry affordance is suppressed.
+ ///
+ public bool HideAddAction { get; init; }
+
+ ///
+ /// Whether the remove-entry affordance is suppressed.
+ ///
+ public bool HideRemoveAction { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiReferenceElement.cs b/Shoko.Abstractions/UI/Elements/UiReferenceElement.cs
new file mode 100644
index 0000000000..889df1854a
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiReferenceElement.cs
@@ -0,0 +1,16 @@
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A pointer to an element in , emitted
+/// where the element tree would otherwise recurse into itself.
+///
+public sealed class UiReferenceElement : UiElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.Reference;
+
+ ///
+ /// The key into .
+ ///
+ public string Reference { get; init; } = string.Empty;
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiSectionContainerElement.cs b/Shoko.Abstractions/UI/Elements/UiSectionContainerElement.cs
new file mode 100644
index 0000000000..d436673baf
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiSectionContainerElement.cs
@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A container holding an ordered set of elements grouped into sections.
+///
+public sealed class UiSectionContainerElement : UiElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.SectionContainer;
+
+ ///
+ /// How the sections should be laid out.
+ ///
+ public DisplaySectionType SectionType { get; init; }
+
+ ///
+ /// The name of the section that holds items without an explicit one.
+ ///
+ public string DefaultSectionName { get; init; } = "Default";
+
+ ///
+ /// Whether sections assembled from items without an explicit section go
+ /// after the other sections instead of before them.
+ ///
+ public bool AppendFloatingSectionsAtEnd { get; init; }
+
+ ///
+ /// Whether the container renders the built-in save action.
+ ///
+ public bool ShowSaveAction { get; init; }
+
+ ///
+ /// The key in of the element that identifies an
+ /// instance of this container when the container is a list item, or
+ /// null when there is none.
+ ///
+ public string? PrimaryKey { get; init; }
+
+ ///
+ /// The elements the user edits, keyed by the property name they are stored
+ /// under in the configuration document — the same key
+ /// carries for a
+ /// entry, so a client can index
+ /// straight into this rather than scanning for a match.
+ ///
+ ///
+ /// Enumerates in order, so a client that renders the
+ /// values in order gets the authored layout without consulting
+ /// at all.
+ ///
+ public IReadOnlyDictionary Items { get; init; } = new Dictionary();
+
+ ///
+ /// The actions attached to this container, keyed by
+ /// — the same key
+ /// carries for a
+ /// entry.
+ ///
+ ///
+ /// Enumerates in order, the same as
+ /// .
+ ///
+ public IReadOnlyDictionary Actions { get; init; } = new Dictionary();
+
+ ///
+ /// and interleaved in the
+ /// order their members were authored, so a client can place an action button
+ /// between two fields. Each entry names which of the two it points into.
+ ///
+ public IReadOnlyList Structure { get; init; } = [];
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiSelectElement.cs b/Shoko.Abstractions/UI/Elements/UiSelectElement.cs
new file mode 100644
index 0000000000..75e3df4e9f
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiSelectElement.cs
@@ -0,0 +1,23 @@
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A selection component whose options are supplied by the server as part of
+/// the configuration value rather than by the definition.
+///
+public sealed class UiSelectElement : UiElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.Select;
+
+ ///
+ /// How the options should be laid out.
+ ///
+ public DisplaySelectType SelectType { get; init; }
+
+ ///
+ /// Whether more than one option may be selected at a time.
+ ///
+ public bool MultipleItems { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiStringElement.cs b/Shoko.Abstractions/UI/Elements/UiStringElement.cs
new file mode 100644
index 0000000000..c57108b66f
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiStringElement.cs
@@ -0,0 +1,10 @@
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A single-line text input.
+///
+public sealed class UiStringElement : UiTextElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.String;
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiTextAreaElement.cs b/Shoko.Abstractions/UI/Elements/UiTextAreaElement.cs
new file mode 100644
index 0000000000..2036da6b6e
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiTextAreaElement.cs
@@ -0,0 +1,10 @@
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// A multi-line text input.
+///
+public sealed class UiTextAreaElement : UiTextElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.TextArea;
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiTextElement.cs b/Shoko.Abstractions/UI/Elements/UiTextElement.cs
new file mode 100644
index 0000000000..7b79aee8b6
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiTextElement.cs
@@ -0,0 +1,28 @@
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// Shared constraints for every element backed by a JSON string.
+///
+public abstract class UiTextElement : UiElement
+{
+ ///
+ /// The shortest accepted value, or null when unbounded.
+ ///
+ public int? MinLength { get; init; }
+
+ ///
+ /// The longest accepted value, or null when unbounded.
+ ///
+ public int? MaxLength { get; init; }
+
+ ///
+ /// A regular expression the value has to match, or null.
+ ///
+ public string? Pattern { get; init; }
+
+ ///
+ /// The JSON schema format hint for the value, such as uri or
+ /// version, or null.
+ ///
+ public string? Format { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/Elements/UiUnknownElement.cs b/Shoko.Abstractions/UI/Elements/UiUnknownElement.cs
new file mode 100644
index 0000000000..a451fd3d4d
--- /dev/null
+++ b/Shoko.Abstractions/UI/Elements/UiUnknownElement.cs
@@ -0,0 +1,16 @@
+namespace Shoko.Abstractions.UI.Elements;
+
+///
+/// Emitted when the server could not map a schema node onto a known element.
+/// Its presence in a definition is a bug report, not a rendering instruction.
+///
+public sealed class UiUnknownElement : UiElement
+{
+ ///
+ public override UiElementKind Kind => UiElementKind.Unknown;
+
+ ///
+ /// The JSON schema type the server saw but could not classify.
+ ///
+ public string? SchemaType { get; init; }
+}
diff --git a/Shoko.Abstractions/Config/Enums/CodeEditorLanguage.cs b/Shoko.Abstractions/UI/Enums/CodeEditorLanguage.cs
similarity index 76%
rename from Shoko.Abstractions/Config/Enums/CodeEditorLanguage.cs
rename to Shoko.Abstractions/UI/Enums/CodeEditorLanguage.cs
index 5b86ffc576..b4d15dacc4 100644
--- a/Shoko.Abstractions/Config/Enums/CodeEditorLanguage.cs
+++ b/Shoko.Abstractions/UI/Enums/CodeEditorLanguage.cs
@@ -1,10 +1,12 @@
-using Shoko.Abstractions.Config.Attributes;
+using Shoko.Abstractions.UI.Attributes;
-namespace Shoko.Abstractions.Config.Enums;
+namespace Shoko.Abstractions.UI.Enums;
///
/// Coding languages for .
///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
public enum CodeEditorLanguage
{
///
diff --git a/Shoko.Abstractions/UI/Enums/DisplayButtonPosition.cs b/Shoko.Abstractions/UI/Enums/DisplayButtonPosition.cs
new file mode 100644
index 0000000000..b8decbd7ec
--- /dev/null
+++ b/Shoko.Abstractions/UI/Enums/DisplayButtonPosition.cs
@@ -0,0 +1,40 @@
+using System.Runtime.Serialization;
+
+namespace Shoko.Abstractions.UI.Enums;
+
+///
+/// Position of an element in the UI.
+///
+///
+/// Every member carries a distinct value. Aliases would share one, and which
+/// name a serializer hands back for a shared value is unspecified — in practice
+/// it reached for the alias and skipped the attributed member, so a button
+/// authored as Start went out as "Left".
+///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
+public enum DisplayButtonPosition
+{
+ ///
+ /// The element is automatically placed.
+ ///
+ [EnumMember(Value = "auto")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")]
+ Auto = 0,
+
+ ///
+ /// The element is placed at the start of the parent component — its top
+ /// edge or its leading edge, depending on how the parent lays out.
+ ///
+ [EnumMember(Value = "start")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("start")]
+ Start = 1,
+
+ ///
+ /// The element is placed at the end of the parent component — its bottom
+ /// edge or its trailing edge, depending on how the parent lays out.
+ ///
+ [EnumMember(Value = "end")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("end")]
+ End = 2,
+}
diff --git a/Shoko.Abstractions/Config/Enums/DisplayColorTheme.cs b/Shoko.Abstractions/UI/Enums/DisplayColorTheme.cs
similarity index 62%
rename from Shoko.Abstractions/Config/Enums/DisplayColorTheme.cs
rename to Shoko.Abstractions/UI/Enums/DisplayColorTheme.cs
index b05c3a04a2..2719175b4d 100644
--- a/Shoko.Abstractions/Config/Enums/DisplayColorTheme.cs
+++ b/Shoko.Abstractions/UI/Enums/DisplayColorTheme.cs
@@ -1,45 +1,53 @@
using System.Runtime.Serialization;
-namespace Shoko.Abstractions.Config.Enums;
+namespace Shoko.Abstractions.UI.Enums;
///
/// Determines the color theme used for an element in the UI.
///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
public enum DisplayColorTheme
{
///
/// The element is displayed as a default themed element in the UI.
///
[EnumMember(Value = "default")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("default")]
Default = 0,
///
/// The element is displayed as a primary themed element in the UI.
///
[EnumMember(Value = "primary")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("primary")]
Primary = 1,
///
/// The element is displayed as a secondary themed element in the UI.
///
[EnumMember(Value = "secondary")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("secondary")]
Secondary = 2,
///
/// The element is displayed as an important themed element in the UI.
///
[EnumMember(Value = "important")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("important")]
Important = 3,
///
/// The element is displayed as a warning themed element in the UI.
///
[EnumMember(Value = "warning")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("warning")]
Warning = 4,
///
/// The element is displayed as a danger themed element in the UI.
///
[EnumMember(Value = "danger")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("danger")]
Danger = 5,
}
diff --git a/Shoko.Abstractions/Config/Enums/DisplayElementSize.cs b/Shoko.Abstractions/UI/Enums/DisplayElementSize.cs
similarity index 59%
rename from Shoko.Abstractions/Config/Enums/DisplayElementSize.cs
rename to Shoko.Abstractions/UI/Enums/DisplayElementSize.cs
index 1e4b93d797..088d5f8df4 100644
--- a/Shoko.Abstractions/Config/Enums/DisplayElementSize.cs
+++ b/Shoko.Abstractions/UI/Enums/DisplayElementSize.cs
@@ -1,33 +1,39 @@
using System.Runtime.Serialization;
-namespace Shoko.Abstractions.Config.Enums;
+namespace Shoko.Abstractions.UI.Enums;
///
/// Determines the size of an element in the UI.
///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
public enum DisplayElementSize
{
///
/// The element will span it's default size in the UI.
///
[EnumMember(Value = "normal")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("normal")]
Normal = 0,
///
/// The element will span less the default size in the UI.
///
[EnumMember(Value = "small")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("small")]
Small = 1,
///
/// The element will span more the default size in the UI.
///
[EnumMember(Value = "large")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("large")]
Large = 2,
///
/// The element will span full size of it's container in the UI.
///
[EnumMember(Value = "full")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("full")]
Full = 3,
}
diff --git a/Shoko.Abstractions/Config/Enums/DisplayElementType.cs b/Shoko.Abstractions/UI/Enums/DisplayElementType.cs
similarity index 60%
rename from Shoko.Abstractions/Config/Enums/DisplayElementType.cs
rename to Shoko.Abstractions/UI/Enums/DisplayElementType.cs
index 199d07165a..52976e2a3a 100644
--- a/Shoko.Abstractions/Config/Enums/DisplayElementType.cs
+++ b/Shoko.Abstractions/UI/Enums/DisplayElementType.cs
@@ -1,10 +1,12 @@
using System.Runtime.Serialization;
-namespace Shoko.Abstractions.Config.Enums;
+namespace Shoko.Abstractions.UI.Enums;
///
/// The type of element in the UI.
///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
public enum DisplayElementType
{
///
@@ -12,53 +14,62 @@ public enum DisplayElementType
/// schema.
///
[EnumMember(Value = "auto")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")]
Auto = 0,
///
/// A container element holding a group of sections in the UI.
///
[EnumMember(Value = "section-container")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("section-container")]
SectionContainer = 1,
///
/// A list element in the UI.
///
[EnumMember(Value = "list")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("list")]
List = 2,
///
/// A record element in the UI.
///
[EnumMember(Value = "record")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("record")]
Record = 3,
///
/// An enum element in the UI.
///
[EnumMember(Value = "enum")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("enum")]
Enum = 4,
///
/// A password element in the UI.
///
[EnumMember(Value = "password")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("password")]
Password = 5,
///
/// A text area element in the UI.
///
[EnumMember(Value = "text-area")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("text-area")]
TextArea = 6,
///
/// A code block element in the UI.
///
[EnumMember(Value = "code-block")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("code-block")]
CodeBlock = 7,
///
/// A select element in the UI.
///
[EnumMember(Value = "select")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("select")]
Select = 8,
}
diff --git a/Shoko.Abstractions/Config/Enums/DisplayListType.cs b/Shoko.Abstractions/UI/Enums/DisplayListType.cs
similarity index 64%
rename from Shoko.Abstractions/Config/Enums/DisplayListType.cs
rename to Shoko.Abstractions/UI/Enums/DisplayListType.cs
index 4acbf0c314..82d922e412 100644
--- a/Shoko.Abstractions/Config/Enums/DisplayListType.cs
+++ b/Shoko.Abstractions/UI/Enums/DisplayListType.cs
@@ -1,22 +1,26 @@
using System.Runtime.Serialization;
-namespace Shoko.Abstractions.Config.Enums;
+namespace Shoko.Abstractions.UI.Enums;
///
/// Types of lists in the UI for a list field/property.
///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
public enum DisplayListType
{
///
/// Auto behavior based on complexity and type.
///
[EnumMember(Value = "auto")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")]
Auto = 0,
///
/// A list where all the options are viewed at once, with checkboxes for each option.
///
[EnumMember(Value = "enum-checkbox")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("enum-checkbox")]
EnumCheckbox = 1,
///
@@ -24,6 +28,7 @@ public enum DisplayListType
/// Only usable by complex list types.
///
[EnumMember(Value = "complex-dropdown")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("complex-dropdown")]
ComplexDropdown = 2,
///
@@ -31,6 +36,7 @@ public enum DisplayListType
/// Only usable by complex list types.
///
[EnumMember(Value = "complex-tab")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("complex-tab")]
ComplexTab = 3,
///
@@ -38,5 +44,6 @@ public enum DisplayListType
/// optionally, with actions per entry.
///
[EnumMember(Value = "complex-inline")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("complex-inline")]
ComplexInline = 4,
}
diff --git a/Shoko.Abstractions/Config/Enums/DisplayRecordType.cs b/Shoko.Abstractions/UI/Enums/DisplayRecordType.cs
similarity index 61%
rename from Shoko.Abstractions/Config/Enums/DisplayRecordType.cs
rename to Shoko.Abstractions/UI/Enums/DisplayRecordType.cs
index cf2b19fe26..88cc4b9a96 100644
--- a/Shoko.Abstractions/Config/Enums/DisplayRecordType.cs
+++ b/Shoko.Abstractions/UI/Enums/DisplayRecordType.cs
@@ -1,16 +1,19 @@
using System.Runtime.Serialization;
-namespace Shoko.Abstractions.Config.Enums;
+namespace Shoko.Abstractions.UI.Enums;
///
/// Types of records in the UI for a record field/property.
///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
public enum DisplayRecordType
{
///
/// Auto behavior based on complexity and type.
///
[EnumMember(Value = "auto")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")]
Auto = 0,
///
@@ -18,6 +21,7 @@ public enum DisplayRecordType
/// Only usable by complex record types.
///
[EnumMember(Value = "complex-dropdown")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("complex-dropdown")]
ComplexDropdown = 1,
///
@@ -25,5 +29,6 @@ public enum DisplayRecordType
/// Only usable by complex record types.
///
[EnumMember(Value = "complex-tab")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("complex-tab")]
ComplexTab = 2,
}
diff --git a/Shoko.Abstractions/Config/Enums/DisplaySectionType.cs b/Shoko.Abstractions/UI/Enums/DisplaySectionType.cs
similarity index 59%
rename from Shoko.Abstractions/Config/Enums/DisplaySectionType.cs
rename to Shoko.Abstractions/UI/Enums/DisplaySectionType.cs
index fb284e359a..2d734d97c8 100644
--- a/Shoko.Abstractions/Config/Enums/DisplaySectionType.cs
+++ b/Shoko.Abstractions/UI/Enums/DisplaySectionType.cs
@@ -1,33 +1,39 @@
using System.Runtime.Serialization;
-namespace Shoko.Abstractions.Config.Enums;
+namespace Shoko.Abstractions.UI.Enums;
///
/// Types of sections in the UI for a class/group.
///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
public enum DisplaySectionType
{
///
/// The sections is displayed as a field-set in the UI.
///
[EnumMember(Value = "field-set")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("field-set")]
FieldSet = 0,
///
/// The sections is displayed as a set of tabs in the UI.
///
[EnumMember(Value = "tab")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("tab")]
Tab = 1,
///
/// The sections is displayed with simple headers in the UI.
///
[EnumMember(Value = "minimal")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("minimal")]
Minimal = 2,
///
/// The sections is displayed as a checkbox list in the UI.
///
[EnumMember(Value = "checkbox")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("checkbox")]
Checkbox = 3,
}
diff --git a/Shoko.Abstractions/Config/Enums/DisplaySelectType.cs b/Shoko.Abstractions/UI/Enums/DisplaySelectType.cs
similarity index 58%
rename from Shoko.Abstractions/Config/Enums/DisplaySelectType.cs
rename to Shoko.Abstractions/UI/Enums/DisplaySelectType.cs
index 772b11cc3b..acd4752c2d 100644
--- a/Shoko.Abstractions/Config/Enums/DisplaySelectType.cs
+++ b/Shoko.Abstractions/UI/Enums/DisplaySelectType.cs
@@ -1,28 +1,33 @@
using System.Runtime.Serialization;
-namespace Shoko.Abstractions.Config.Enums;
+namespace Shoko.Abstractions.UI.Enums;
///
/// Types of selects in the UI for a select field/property.
///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
public enum DisplaySelectType
{
///
/// Auto behavior based on complexity and type.
///
[EnumMember(Value = "auto")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")]
Auto = 0,
///
/// A flat list where all the options are viewed at once.
///
[EnumMember(Value = "flat-list")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("flat-list")]
FlatList = 1,
///
/// A flat list where all the options are viewed at once, with checkboxes for each option.
///
[EnumMember(Value = "checkbox-list")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("checkbox-list")]
CheckboxList = 2,
}
diff --git a/Shoko.Abstractions/Config/Enums/DisplayVisibility.cs b/Shoko.Abstractions/UI/Enums/DisplayVisibility.cs
similarity index 51%
rename from Shoko.Abstractions/Config/Enums/DisplayVisibility.cs
rename to Shoko.Abstractions/UI/Enums/DisplayVisibility.cs
index d888e52d10..7eb7f3fc8e 100644
--- a/Shoko.Abstractions/Config/Enums/DisplayVisibility.cs
+++ b/Shoko.Abstractions/UI/Enums/DisplayVisibility.cs
@@ -1,27 +1,32 @@
using System.Runtime.Serialization;
-namespace Shoko.Abstractions.Config.Enums;
+namespace Shoko.Abstractions.UI.Enums;
///
-/// The visibility of a configuration property/field in the UI.
+/// The visibility of a property/field in the UI.
///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
public enum DisplayVisibility
{
///
/// The property/field is visible in the UI.
///
[EnumMember(Value = "visible")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("visible")]
Visible = 0,
///
/// The property/field is hidden in the UI.
///
[EnumMember(Value = "hidden")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("hidden")]
Hidden = 1,
///
/// The property/field is marked as read-only in the UI.
///
[EnumMember(Value = "read-only")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("read-only")]
ReadOnly = 2,
}
diff --git a/Shoko.Abstractions/UI/UiAction.cs b/Shoko.Abstractions/UI/UiAction.cs
new file mode 100644
index 0000000000..a17f2ca196
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiAction.cs
@@ -0,0 +1,71 @@
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Abstractions.UI;
+
+///
+/// A user-invokable action attached to a section container.
+///
+public class UiAction
+{
+ ///
+ /// The identifier to send back to the server when the action is invoked.
+ ///
+ public string ID { get; init; } = string.Empty;
+
+ ///
+ /// The label of the action's button.
+ ///
+ public string Title { get; init; } = string.Empty;
+
+ ///
+ /// An optional longer description, usable as a tooltip.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// The colour theme of the action's button.
+ ///
+ public DisplayColorTheme Theme { get; init; }
+
+ ///
+ /// Where in the container the action's button belongs.
+ ///
+ public DisplayButtonPosition Position { get; init; }
+
+ ///
+ /// The authored size of the action's button.
+ ///
+ public DisplayElementSize Size { get; init; }
+
+ ///
+ /// An optional icon name for the action's button.
+ ///
+ public string? Icon { get; init; }
+
+ ///
+ /// The section within the container the action belongs to, or null
+ /// for the container's default section.
+ ///
+ public string? SectionName { get; init; }
+
+ ///
+ /// The member the action is attached to, or null when the action
+ /// belongs to the container itself.
+ ///
+ public string? MemberName { get; init; }
+
+ ///
+ /// A condition controlling whether the action is shown at all.
+ ///
+ public UiCondition? Toggle { get; init; }
+
+ ///
+ /// A condition controlling whether the action is disabled.
+ ///
+ public UiCondition? Disable { get; init; }
+
+ ///
+ /// Whether the action is disabled while the configuration is unmodified.
+ ///
+ public bool DisableIfNoChanges { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/UiBadge.cs b/Shoko.Abstractions/UI/UiBadge.cs
new file mode 100644
index 0000000000..b46df5ed60
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiBadge.cs
@@ -0,0 +1,19 @@
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Abstractions.UI;
+
+///
+/// A small labelled marker rendered next to an element's label.
+///
+public class UiBadge
+{
+ ///
+ /// The text shown inside the badge.
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// The colour theme of the badge.
+ ///
+ public DisplayColorTheme Theme { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/UiCondition.cs b/Shoko.Abstractions/UI/UiCondition.cs
new file mode 100644
index 0000000000..7e694473e8
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiCondition.cs
@@ -0,0 +1,25 @@
+using Newtonsoft.Json.Linq;
+
+namespace Shoko.Abstractions.UI;
+
+///
+/// A condition evaluated against another value in the same configuration.
+///
+public class UiCondition
+{
+ ///
+ /// Dotted path to the value to compare, relative to the nearest enclosing
+ /// object.
+ ///
+ public string Path { get; init; } = string.Empty;
+
+ ///
+ /// The value the path has to equal for the condition to hold.
+ ///
+ public JToken? Value { get; init; }
+
+ ///
+ /// Whether the outcome of the comparison should be inverted.
+ ///
+ public bool InverseCondition { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/UiDefinition.cs b/Shoko.Abstractions/UI/UiDefinition.cs
new file mode 100644
index 0000000000..a307adf744
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiDefinition.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+
+namespace Shoko.Abstractions.UI;
+
+///
+/// A self-sufficient description of how to render an editor for a set of
+/// values, derived from the values' JSON schema and their authoring
+/// attributes.
+///
+///
+///
+/// A client should be able to render the whole editor, and run the cheap
+/// pre-submit constraint checks, from this object alone. The JSON schema
+/// remains the authority for server-side validation.
+///
+///
+/// Nothing here is specific to what owns the definition, so the same shape
+/// describes a configuration and an executable action's invocation parameters
+/// alike, and a client renders both the same way.
+///
+///
+public class UiDefinition
+{
+ ///
+ /// The id of whatever this definition describes — a configuration's id, or
+ /// an executable action's id when it describes that action's parameters.
+ ///
+ public Guid ID { get; init; }
+
+ ///
+ /// The display name of whatever this definition describes.
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// An optional longer description of whatever this definition describes.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// The root element of the editor.
+ ///
+ public UiElement Root { get; init; } = null!;
+
+ ///
+ /// Elements hoisted out of because inlining them would
+ /// have recursed forever. Keyed by the name a
+ /// points at.
+ ///
+ public IReadOnlyDictionary Definitions { get; init; } = new Dictionary();
+}
diff --git a/Shoko.Abstractions/UI/UiElement.cs b/Shoko.Abstractions/UI/UiElement.cs
new file mode 100644
index 0000000000..339c9141af
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiElement.cs
@@ -0,0 +1,94 @@
+using System.Collections.Generic;
+using Newtonsoft.Json.Linq;
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Abstractions.UI;
+
+///
+/// Base class for every node in a .
+///
+///
+/// The element tree is meant to be self-sufficient for rendering: a client
+/// should never need to consult the JSON schema the definition was derived from
+/// in order to draw the element or to run a cheap pre-submit check on it.
+///
+public abstract class UiElement
+{
+ ///
+ /// Discriminator naming the concrete subclass. Serialised as a plain
+ /// property so no type-name handling is needed on either side.
+ ///
+ public abstract UiElementKind Kind { get; }
+
+ ///
+ /// The key the element's container files it under, or null when it is
+ /// not filed under one — a list's item, a record's key or value.
+ ///
+ ///
+ /// For an element in
+ /// this repeats the map's key, so an element handed around on its own still
+ /// knows what it edits.
+ ///
+ public string? Key { get; set; }
+
+ ///
+ /// The human-readable label for the element.
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// An optional longer description of the element.
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// How much room the element should take up in its container.
+ ///
+ public DisplayElementSize Size { get; set; }
+
+ ///
+ /// When and whether the element is shown and editable.
+ ///
+ public UiVisibility Visibility { get; set; } = new();
+
+ ///
+ /// An optional badge to render next to the label.
+ ///
+ public UiBadge? Badge { get; set; }
+
+ ///
+ /// Whether changing this element requires a server restart to take effect.
+ ///
+ public bool RequiresRestart { get; set; }
+
+ ///
+ /// The environment variable backing this element, if any.
+ ///
+ public UiEnvironmentVariable? EnvironmentVariable { get; set; }
+
+ ///
+ /// The name of the section within the parent container this element belongs
+ /// to, or null to place it in the container's default section.
+ ///
+ public string? SectionName { get; set; }
+
+ ///
+ /// The default value for the element, if the schema declared one.
+ ///
+ public JToken? Default { get; set; }
+
+ ///
+ /// Whether the parent requires this element to be present.
+ ///
+ public bool IsRequired { get; set; }
+
+ ///
+ /// Whether null is a legal value for this element.
+ ///
+ public bool IsNullable { get; set; }
+
+ ///
+ /// Values the element must not be set to, or null when unrestricted.
+ ///
+ public IReadOnlyList? DeniedValues { get; set; }
+}
diff --git a/Shoko.Abstractions/UI/UiElementKind.cs b/Shoko.Abstractions/UI/UiElementKind.cs
new file mode 100644
index 0000000000..dc40595494
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiElementKind.cs
@@ -0,0 +1,118 @@
+using System.Runtime.Serialization;
+
+namespace Shoko.Abstractions.UI;
+
+///
+/// Discriminator for the concrete subclass carried by a
+/// node in a .
+///
+///
+/// Every value maps to exactly one renderer on the client. There is
+/// deliberately no auto member; the server resolves the authored intent
+/// down to a concrete element before the definition leaves the process.
+///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
+public enum UiElementKind
+{
+ ///
+ /// The server was unable to map the underlying schema node onto a known
+ /// element. The client should render a placeholder.
+ ///
+ [EnumMember(Value = "unknown")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("unknown")]
+ Unknown = 0,
+
+ ///
+ /// A pointer into , emitted where the
+ /// element tree would otherwise recurse into itself.
+ ///
+ [EnumMember(Value = "reference")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("reference")]
+ Reference = 1,
+
+ ///
+ /// A container holding an ordered list of child elements grouped into
+ /// sections.
+ ///
+ [EnumMember(Value = "section-container")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("section-container")]
+ SectionContainer = 2,
+
+ ///
+ /// A boolean toggle.
+ ///
+ [EnumMember(Value = "boolean")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("boolean")]
+ Boolean = 3,
+
+ ///
+ /// A whole-number input.
+ ///
+ [EnumMember(Value = "integer")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("integer")]
+ Integer = 4,
+
+ ///
+ /// A fractional-number input.
+ ///
+ [EnumMember(Value = "float")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("float")]
+ Float = 5,
+
+ ///
+ /// A single-line text input.
+ ///
+ [EnumMember(Value = "string")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("string")]
+ String = 6,
+
+ ///
+ /// A multi-line text input.
+ ///
+ [EnumMember(Value = "text-area")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("text-area")]
+ TextArea = 7,
+
+ ///
+ /// A masked text input.
+ ///
+ [EnumMember(Value = "password")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("password")]
+ Password = 8,
+
+ ///
+ /// A syntax-highlighted code editor.
+ ///
+ [EnumMember(Value = "code-editor")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("code-editor")]
+ CodeEditor = 9,
+
+ ///
+ /// A choice between a fixed set of named values.
+ ///
+ [EnumMember(Value = "enum")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("enum")]
+ Enum = 10,
+
+ ///
+ /// An ordered collection of items of a single element kind.
+ ///
+ [EnumMember(Value = "list")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("list")]
+ List = 11,
+
+ ///
+ /// A keyed collection of values of a single element kind.
+ ///
+ [EnumMember(Value = "record")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("record")]
+ Record = 12,
+
+ ///
+ /// A server-populated selection component.
+ ///
+ [EnumMember(Value = "select")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("select")]
+ Select = 13,
+}
diff --git a/Shoko.Abstractions/UI/UiEnumValue.cs b/Shoko.Abstractions/UI/UiEnumValue.cs
new file mode 100644
index 0000000000..a0de0256cc
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiEnumValue.cs
@@ -0,0 +1,36 @@
+namespace Shoko.Abstractions.UI;
+
+///
+/// One selectable value of a .
+///
+public class UiEnumValue
+{
+ ///
+ /// The human-readable name of the value.
+ ///
+ public string Title { get; init; } = string.Empty;
+
+ ///
+ /// An optional longer description of the value.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// The value as it appears in the configuration document.
+ ///
+ public string Value { get; init; } = string.Empty;
+
+ ///
+ /// The display names of any aliases that collapsed onto this value, or
+ /// null when it has none. A renderer may surface these alongside the
+ /// title so a value is findable by every name it is known by.
+ ///
+ public string? Alias { get; init; }
+
+ ///
+ /// The document values of any aliases that collapsed onto this value, or
+ /// null when it has none. A configuration may legitimately be
+ /// written with one of these instead of .
+ ///
+ public string? AliasValues { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/UiEnvironmentVariable.cs b/Shoko.Abstractions/UI/UiEnvironmentVariable.cs
new file mode 100644
index 0000000000..1e85c43e86
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiEnvironmentVariable.cs
@@ -0,0 +1,17 @@
+namespace Shoko.Abstractions.UI;
+
+///
+/// Describes the environment variable backing an element.
+///
+public class UiEnvironmentVariable
+{
+ ///
+ /// The name of the environment variable.
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// Whether the user may override the loaded value from the client.
+ ///
+ public bool AllowOverride { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/UiStructureEntry.cs b/Shoko.Abstractions/UI/UiStructureEntry.cs
new file mode 100644
index 0000000000..ad58dd6e86
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiStructureEntry.cs
@@ -0,0 +1,53 @@
+using System.Runtime.Serialization;
+
+namespace Shoko.Abstractions.UI;
+
+///
+/// The authored order of a container's members, actions included.
+///
+///
+/// and
+/// each enumerate in
+/// render order already, but they are two separate maps; this one interleaves
+/// them, so a client that wants to place an action button between two fields
+/// knows where it goes.
+///
+public class UiStructureEntry
+{
+ ///
+ /// The key to look the member up by: for a
+ /// the key it is filed under in
+ /// , and for a
+ /// the key it is filed under in
+ /// , which is the
+ /// action's .
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// Which of the container's two maps points into.
+ ///
+ public UiStructureMemberKind Kind { get; init; }
+}
+
+///
+/// What a refers to.
+///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
+public enum UiStructureMemberKind
+{
+ ///
+ /// An element the user edits.
+ ///
+ [EnumMember(Value = "item")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("item")]
+ Item = 0,
+
+ ///
+ /// An action the user invokes.
+ ///
+ [EnumMember(Value = "action")]
+ [System.Text.Json.Serialization.JsonStringEnumMemberName("action")]
+ Action = 1,
+}
diff --git a/Shoko.Abstractions/UI/UiVisibility.cs b/Shoko.Abstractions/UI/UiVisibility.cs
new file mode 100644
index 0000000000..0df692cc1e
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiVisibility.cs
@@ -0,0 +1,31 @@
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Abstractions.UI;
+
+///
+/// Describes when an element is shown and when it is editable.
+///
+public class UiVisibility
+{
+ ///
+ /// The visibility to use when no condition applies.
+ ///
+ public DisplayVisibility Default { get; init; }
+
+ ///
+ /// Whether the element is only shown while the client is in advanced mode.
+ ///
+ public bool Advanced { get; init; }
+
+ ///
+ /// A condition that switches the element to another visibility, or
+ /// null when the visibility never changes.
+ ///
+ public UiVisibilityCondition? Toggle { get; init; }
+
+ ///
+ /// A condition that makes the element read-only, or null when the
+ /// element is never conditionally disabled.
+ ///
+ public UiCondition? Disable { get; init; }
+}
diff --git a/Shoko.Abstractions/UI/UiVisibilityCondition.cs b/Shoko.Abstractions/UI/UiVisibilityCondition.cs
new file mode 100644
index 0000000000..38d4bc608d
--- /dev/null
+++ b/Shoko.Abstractions/UI/UiVisibilityCondition.cs
@@ -0,0 +1,15 @@
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Abstractions.UI;
+
+///
+/// A that switches an element to a different
+/// visibility while it holds.
+///
+public class UiVisibilityCondition : UiCondition
+{
+ ///
+ /// The visibility to apply while the condition holds.
+ ///
+ public DisplayVisibility Visibility { get; init; }
+}
diff --git a/Shoko.BuildTools.Analyzers/AnalyzerReleases.Shipped.md b/Shoko.BuildTools.Analyzers/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000000..f50bb1fe21
--- /dev/null
+++ b/Shoko.BuildTools.Analyzers/AnalyzerReleases.Shipped.md
@@ -0,0 +1,2 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
diff --git a/Shoko.BuildTools.Analyzers/AnalyzerReleases.Unshipped.md b/Shoko.BuildTools.Analyzers/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000000..13e36f6687
--- /dev/null
+++ b/Shoko.BuildTools.Analyzers/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,12 @@
+; Unshipped analyzer release
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+SHOKO0001 | Shoko.Configuration | Error | ConfigurationTypeAnalyzer, a collection nested directly inside another collection cannot be described by the UI schema.
+SHOKO0002 | Shoko.Configuration | Error | ConfigurationTypeAnalyzer, a dictionary key that is not serializable to text makes UI schema generation throw.
+SHOKO0003 | Shoko.Configuration | Error | ConfigurationTypeAnalyzer, a `[List]` display type that the element type cannot support makes UI schema generation throw.
+SHOKO0004 | Shoko.Configuration | Error | ConfigurationTypeAnalyzer, a complex `[List]` display type without a `[Key]` property makes UI schema generation throw.
+SHOKO0005 | Shoko.Configuration | Error | ConfigurationTypeAnalyzer, a record-shaped property that is not a generic dictionary makes UI schema generation throw.
diff --git a/Shoko.BuildTools.Analyzers/CollectionShape.cs b/Shoko.BuildTools.Analyzers/CollectionShape.cs
new file mode 100644
index 0000000000..0048d5d38a
--- /dev/null
+++ b/Shoko.BuildTools.Analyzers/CollectionShape.cs
@@ -0,0 +1,194 @@
+using Microsoft.CodeAnalysis;
+
+namespace Shoko.BuildTools.Analyzers;
+
+///
+/// How the configuration UI schema generator will describe a type.
+///
+internal enum CollectionKind
+{
+ ///
+ /// Not a collection; rendered as a scalar or as a section.
+ ///
+ None = 0,
+
+ ///
+ /// Rendered as a JSON array, and keyed with a +List suffix.
+ ///
+ List = 1,
+
+ ///
+ /// Rendered as a JSON object with additionalProperties, and keyed with a +Dict suffix.
+ ///
+ Dictionary = 2,
+}
+
+///
+/// The collection shape of a type, as the configuration UI schema generator sees it.
+///
+/// The kind of collection, if any.
+///
+/// The element type for a list, or the value type for a dictionary. Only meaningful when
+/// is not .
+///
+/// The key type, for a dictionary.
+internal readonly struct CollectionShape(CollectionKind kind, ITypeSymbol? element, ITypeSymbol? key)
+{
+ ///
+ /// A type that is not a collection.
+ ///
+ public static readonly CollectionShape None = new(CollectionKind.None, null, null);
+
+ ///
+ /// The kind of collection, if any.
+ ///
+ public CollectionKind Kind { get; } = kind;
+
+ ///
+ /// The element type for a list, or the value type for a dictionary.
+ ///
+ public ITypeSymbol? Element { get; } = element;
+
+ ///
+ /// The key type, for a dictionary.
+ ///
+ public ITypeSymbol? Key { get; } = key;
+
+ ///
+ /// The word to use for this collection kind in a diagnostic message.
+ ///
+ public string Noun => Kind switch
+ {
+ CollectionKind.List => "list",
+ CollectionKind.Dictionary => "dictionary",
+ _ => "value",
+ };
+
+ ///
+ /// Classifies a type the same way the configuration UI schema generator does: dictionaries
+ /// first, then anything else enumerable, with the types the generator maps to a JSON scalar
+ /// excluded.
+ ///
+ /// The type to classify.
+ /// The symbols for the current compilation.
+ /// The collection shape of .
+ public static CollectionShape Classify(ITypeSymbol? type, KnownSymbols known)
+ {
+ if (Unwrap(type) is not { } unwrapped)
+ return None;
+
+ // string is IEnumerable, and byte[] is emitted as a base64 string, so neither is an
+ // array in the generated schema.
+ if (unwrapped.SpecialType is SpecialType.System_String)
+ return None;
+ if (unwrapped is IArrayTypeSymbol { ElementType.SpecialType: SpecialType.System_Byte })
+ return None;
+
+ // Newtonsoft and System.Text.Json DOM types implement collection interfaces but are not
+ // configuration shapes at all. Never claim to know what the generator does with them.
+ foreach (var nonCollection in known.NonCollectionBaseTypes)
+ {
+ for (var current = unwrapped; current is not null; current = current.BaseType)
+ {
+ if (SymbolEqualityComparer.Default.Equals(current, nonCollection))
+ return None;
+ }
+ }
+
+ if (unwrapped is IArrayTypeSymbol array)
+ return new CollectionShape(CollectionKind.List, array.ElementType, null);
+
+ if (FindConstructed(unwrapped, known.GenericDictionary) is { } dictionary)
+ return new CollectionShape(CollectionKind.Dictionary, dictionary.TypeArguments[1], dictionary.TypeArguments[0]);
+ if (FindConstructed(unwrapped, known.GenericReadOnlyDictionary) is { } readOnlyDictionary)
+ return new CollectionShape(CollectionKind.Dictionary, readOnlyDictionary.TypeArguments[1], readOnlyDictionary.TypeArguments[0]);
+
+ if (FindEnumerable(unwrapped) is { } enumerable)
+ return new CollectionShape(CollectionKind.List, enumerable.TypeArguments[0], null);
+
+ // A non-generic dictionary still becomes a JSON object with additionalProperties, but the
+ // generator cannot read a key or value type off it. Reported by SHOKO0005 on its own.
+ if (known.NonGenericDictionary is not null && Implements(unwrapped, known.NonGenericDictionary))
+ return new CollectionShape(CollectionKind.Dictionary, null, null);
+
+ return None;
+ }
+
+ ///
+ /// Strips the wrapper, so a nullable value type is classified
+ /// as the type it wraps.
+ ///
+ /// The type to unwrap.
+ /// The unwrapped type, or when there is nothing to classify.
+ public static ITypeSymbol? Unwrap(ITypeSymbol? type)
+ => type switch
+ {
+ null => null,
+ IErrorTypeSymbol => null,
+ ITypeParameterSymbol => null,
+ IDynamicTypeSymbol => null,
+ INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable => nullable.TypeArguments[0],
+ _ => type,
+ };
+
+ ///
+ /// Whether the type is, or implements, the given non-generic interface.
+ ///
+ /// The type to test.
+ /// The interface to look for.
+ /// when the interface is implemented.
+ public static bool Implements(ITypeSymbol type, INamedTypeSymbol interfaceType)
+ {
+ if (SymbolEqualityComparer.Default.Equals(type, interfaceType))
+ return true;
+
+ foreach (var candidate in type.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(candidate, interfaceType))
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Whether the type is a generic dictionary, which is what
+ /// ShokoJsonSchemaGenerator.GetTKeyAndTValue requires of anything it renders as a record.
+ ///
+ /// The type to test.
+ /// The symbols for the current compilation.
+ /// when the type is a generic dictionary.
+ public static bool IsGenericDictionary(ITypeSymbol type, KnownSymbols known)
+ => FindConstructed(type, known.GenericDictionary) is not null || FindConstructed(type, known.GenericReadOnlyDictionary) is not null;
+
+ private static INamedTypeSymbol? FindConstructed(ITypeSymbol type, INamedTypeSymbol? definition)
+ {
+ if (definition is null)
+ return null;
+
+ if (type is INamedTypeSymbol named && SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, definition))
+ return named;
+
+ foreach (var candidate in type.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(candidate.OriginalDefinition, definition))
+ return candidate;
+ }
+
+ return null;
+ }
+
+ private static INamedTypeSymbol? FindEnumerable(ITypeSymbol type)
+ {
+ if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Collections_Generic_IEnumerable_T } named)
+ return named;
+
+ foreach (var candidate in type.AllInterfaces)
+ {
+ if (candidate.OriginalDefinition.SpecialType is SpecialType.System_Collections_Generic_IEnumerable_T)
+ return candidate;
+ }
+
+ return null;
+ }
+}
diff --git a/Shoko.BuildTools.Analyzers/ConfigurationMembers.cs b/Shoko.BuildTools.Analyzers/ConfigurationMembers.cs
new file mode 100644
index 0000000000..202b047cb3
--- /dev/null
+++ b/Shoko.BuildTools.Analyzers/ConfigurationMembers.cs
@@ -0,0 +1,146 @@
+using Microsoft.CodeAnalysis;
+
+namespace Shoko.BuildTools.Analyzers;
+
+///
+/// Shared member-level predicates, kept in one place so the reachability walk and the rules agree
+/// on which members the configuration UI schema generator actually sees.
+///
+internal static class ConfigurationMembers
+{
+ ///
+ /// Whether the property ends up in the generated JSON schema at all.
+ ///
+ ///
+ /// Deliberately conservative. Non-public members, write-only members and members either JSON
+ /// serializer would ignore are skipped, so an uncertain case never becomes a build error.
+ ///
+ /// The property to test.
+ /// The symbols for the current compilation.
+ /// when the property reaches the schema generator.
+ public static bool ReachesSchemaGenerator(IPropertySymbol property, KnownSymbols known)
+ {
+ if (property.IsStatic || property.IsIndexer || property.IsImplicitlyDeclared)
+ return false;
+ if (property.DeclaredAccessibility is not Accessibility.Public)
+ return false;
+ if (property.GetMethod is null || property.ExplicitInterfaceImplementations.Length > 0)
+ return false;
+ if (known.NewtonsoftJsonIgnoreAttribute is not null && HasAttribute(property, known.NewtonsoftJsonIgnoreAttribute))
+ return false;
+ if (known.SystemTextJsonIgnoreAttribute is not null && HasAttribute(property, known.SystemTextJsonIgnoreAttribute))
+ return false;
+ if (known.JsonSchemaIgnoreAttribute is not null && HasAttribute(property, known.JsonSchemaIgnoreAttribute))
+ return false;
+
+ return true;
+ }
+
+ ///
+ /// Whether the schema generator will render the type as a section container, which is the
+ /// condition the complex list display types check through listElementType.
+ ///
+ ///
+ /// Mirrors the registration condition in ShokoJsonSchemaGenerator: the type's schema has
+ /// at least one property, and its full name is under neither System. nor
+ /// Shoko.Abstractions.UI.Components..
+ ///
+ /// The type to test.
+ /// The symbols for the current compilation.
+ /// when the type renders as a section container.
+ public static bool IsSectionContainer(ITypeSymbol? type, KnownSymbols known)
+ {
+ if (CollectionShape.Unwrap(type) is not INamedTypeSymbol named)
+ return false;
+ if (named.TypeKind is not (TypeKind.Class or TypeKind.Struct or TypeKind.Interface))
+ return false;
+ if (named.SpecialType is not SpecialType.None)
+ return false;
+
+ var fullName = named.ToDisplayString();
+ if (fullName.StartsWith("System.", StringComparison.Ordinal) || fullName.StartsWith("Shoko.Abstractions.UI.Components.", StringComparison.Ordinal))
+ return false;
+
+ // Inherited properties are flattened into the derived type's schema, so the whole chain counts.
+ foreach (var current in SelfAndBases(named))
+ {
+ foreach (var member in current.GetMembers())
+ {
+ if (member is IPropertySymbol property && ReachesSchemaGenerator(property, known))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Whether the type declares a property the generator will pick up as the primary key.
+ ///
+ ///
+ /// Only properties declared by the type itself count. The generator files a property's UI
+ /// metadata under MemberInfo.ReflectedType, which for an inherited property is the base
+ /// type, so an inherited [Key] never reaches the derived type's primary key scan. This
+ /// mirrors that rather than the intent.
+ ///
+ /// The type to test.
+ /// The symbols for the current compilation.
+ /// when the type declares a primary key.
+ public static bool DeclaresPrimaryKey(ITypeSymbol? type, KnownSymbols known)
+ {
+ if (known.KeyAttribute is null || CollectionShape.Unwrap(type) is not INamedTypeSymbol named)
+ return false;
+
+ // The base chain counts: the schema flattens inheritance, and the
+ // generator resolves an inherited key through the flattened property
+ // set. Reporting one here would be an error on code that builds a
+ // perfectly good schema.
+ foreach (var current in SelfAndBases(named))
+ {
+ foreach (var member in current.GetMembers())
+ {
+ if (member is IPropertySymbol property && ReachesSchemaGenerator(property, known) && HasAttribute(property, known.KeyAttribute))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// The type and every base type up to, but excluding, .
+ ///
+ /// The type to walk from.
+ /// The type and its base types.
+ public static IEnumerable SelfAndBases(INamedTypeSymbol type)
+ {
+ for (var current = type; current is not null && current.SpecialType is not SpecialType.System_Object; current = current.BaseType)
+ yield return current;
+ }
+
+ ///
+ /// Whether the symbol carries the given attribute.
+ ///
+ /// The symbol to inspect.
+ /// The attribute type to look for.
+ /// when the attribute is present.
+ public static bool HasAttribute(ISymbol symbol, INamedTypeSymbol attributeType)
+ => FindAttribute(symbol, attributeType) is not null;
+
+ ///
+ /// Finds the given attribute on the symbol.
+ ///
+ /// The symbol to inspect.
+ /// The attribute type to look for.
+ /// The attribute, or when it is not present.
+ public static AttributeData? FindAttribute(ISymbol symbol, INamedTypeSymbol attributeType)
+ {
+ foreach (var attribute in symbol.GetAttributes())
+ {
+ if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType))
+ return attribute;
+ }
+
+ return null;
+ }
+}
diff --git a/Shoko.BuildTools.Analyzers/ConfigurationTypeAnalyzer.cs b/Shoko.BuildTools.Analyzers/ConfigurationTypeAnalyzer.cs
new file mode 100644
index 0000000000..4a1aa93d97
--- /dev/null
+++ b/Shoko.BuildTools.Analyzers/ConfigurationTypeAnalyzer.cs
@@ -0,0 +1,299 @@
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Shoko.BuildTools.Analyzers;
+
+///
+/// Reports property shapes that compile fine but that the UI schema generator cannot describe,
+/// either because it silently drops the metadata or because it throws while building the schema.
+///
+///
+///
+/// Both the properties of a configuration and the invocation parameters of an executable action are
+/// walked by the same generator, and the same shapes break both, so both are analysed. See
+/// for how a root is picked.
+///
+///
+/// This is defence in depth. The generator keeps its own runtime validation, because a plugin can
+/// be built without ever referencing this package.
+///
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class ConfigurationTypeAnalyzer : DiagnosticAnalyzer
+{
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(
+ Diagnostics.NestedCollection,
+ Diagnostics.UnusableDictionaryKey,
+ Diagnostics.IncompatibleListType,
+ Diagnostics.MissingPrimaryKey,
+ Diagnostics.NotAGenericDictionary);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(static start =>
+ {
+ if (KnownSymbols.TryCreate(start.Compilation) is not { } known)
+ return;
+
+ var index = new ConfigurationTypeIndex(start.Compilation, known);
+ var reported = new ConcurrentDictionary(StringComparer.Ordinal);
+ start.RegisterSymbolAction(context => AnalyzeType(context, known, index, reported), SymbolKind.NamedType);
+ });
+ }
+
+ private static void AnalyzeType(SymbolAnalysisContext context, KnownSymbols known, ConfigurationTypeIndex index, ConcurrentDictionary reported)
+ {
+ var type = (INamedTypeSymbol)context.Symbol;
+ if (!index.Contains(type))
+ return;
+
+ // Walking the base chain from the derived type substitutes the type arguments, so a
+ // 'Base { List Items }' inherited as 'Base>' is seen as
+ // 'List>' here even though the declaration itself is fine.
+ var seenNames = new HashSet(StringComparer.Ordinal);
+ for (var current = type; current is not null && current.SpecialType is not SpecialType.System_Object; current = current.BaseType)
+ {
+ foreach (var member in current.GetMembers())
+ {
+ if (member is not IPropertySymbol property || !seenNames.Add(property.Name))
+ continue;
+ if (!ConfigurationMembers.ReachesSchemaGenerator(property, known))
+ continue;
+
+ AnalyzeProperty(context, property, type, known, reported);
+ }
+ }
+ }
+
+ private static void AnalyzeProperty(SymbolAnalysisContext context, IPropertySymbol property, INamedTypeSymbol owner, KnownSymbols known, ConcurrentDictionary reported)
+ {
+ var shape = CollectionShape.Classify(property.Type, known);
+ if (shape.Kind is CollectionKind.None)
+ return;
+
+ var inner = CollectionShape.Classify(shape.Element, known);
+ // A dictionary of collections is fine: the two levels get distinct keys
+ // (`+Dict` and `+List`) and the generator produces a usable schema. Only
+ // same-kind nesting collides on one key, and only a dictionary inside a
+ // list makes the key resolver read the wrong type and throw.
+ if (inner.Kind is not CollectionKind.None && !(shape.Kind is CollectionKind.Dictionary && inner.Kind is CollectionKind.List))
+ {
+ Report(context, reported, Diagnostic.Create(
+ Diagnostics.NestedCollection,
+ GetTypeLocation(property, owner, context.CancellationToken),
+ property.Name,
+ property.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat),
+ shape.Noun));
+ // The outer shape is already wrong, so the remaining rules would only add noise.
+ return;
+ }
+
+ if (shape.Kind is CollectionKind.Dictionary)
+ {
+ // GetTKeyAndTValue runs before AssertKeyUsable, so a non-generic dictionary fails there
+ // first and never reaches the key check.
+ if (!CollectionShape.IsGenericDictionary(property.Type, known))
+ {
+ Report(context, reported, Diagnostic.Create(
+ Diagnostics.NotAGenericDictionary,
+ GetTypeLocation(property, owner, context.CancellationToken),
+ property.Name,
+ property.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)));
+ }
+ else if (!IsUsableDictionaryKey(shape.Key, known))
+ {
+ Report(context, reported, Diagnostic.Create(
+ Diagnostics.UnusableDictionaryKey,
+ GetTypeLocation(property, owner, context.CancellationToken),
+ property.Name,
+ shape.Key!.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)));
+ }
+ }
+
+ if (shape.Kind is CollectionKind.List)
+ AnalyzeListType(context, property, owner, shape.Element, known, reported);
+ }
+
+ ///
+ /// Reports a diagnostic, dropping the repeats that come from several configurations inheriting
+ /// the same member.
+ ///
+ private static void Report(SymbolAnalysisContext context, ConcurrentDictionary reported, Diagnostic diagnostic)
+ {
+ if (reported.TryAdd(diagnostic.ToString(), 0))
+ context.ReportDiagnostic(diagnostic);
+ }
+
+ private static void AnalyzeListType(SymbolAnalysisContext context, IPropertySymbol property, INamedTypeSymbol owner, ITypeSymbol? element, KnownSymbols known, ConcurrentDictionary reported)
+ {
+ if (known.ListAttribute is null || element is null)
+ return;
+ if (ConfigurationMembers.FindAttribute(property, known.ListAttribute) is not { } attribute)
+ return;
+ if (GetListType(attribute) is not { } listType)
+ return;
+
+ // Auto is the only display type the generator never rejects.
+ if (listType is DisplayListType.Auto)
+ return;
+
+ var location = attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation()
+ ?? GetTypeLocation(property, owner, context.CancellationToken);
+ var noun = GetListTypeNoun(listType);
+ if (listType is DisplayListType.EnumCheckbox)
+ {
+ if (CollectionShape.Unwrap(element) is not { TypeKind: TypeKind.Enum })
+ {
+ Report(context, reported, Diagnostic.Create(
+ Diagnostics.IncompatibleListType,
+ location,
+ property.Name,
+ noun,
+ "enum",
+ listType.ToString(),
+ element.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)));
+ }
+
+ return;
+ }
+
+ // The generator checks the element type first and only then the primary key, so report at
+ // most one of the two for a given property.
+ if (!ConfigurationMembers.IsSectionContainer(element, known))
+ {
+ // An element type the analyzer cannot resolve is left alone rather than guessed at.
+ if (CollectionShape.Unwrap(element) is not null)
+ {
+ Report(context, reported, Diagnostic.Create(
+ Diagnostics.IncompatibleListType,
+ location,
+ property.Name,
+ noun,
+ "class",
+ listType.ToString(),
+ element.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)));
+ }
+
+ return;
+ }
+
+ if (known.KeyAttribute is null)
+ return;
+ if (ConfigurationMembers.HasAttribute(property, known.KeyAttribute) || ConfigurationMembers.DeclaresPrimaryKey(element, known))
+ return;
+
+ Report(context, reported, Diagnostic.Create(
+ Diagnostics.MissingPrimaryKey,
+ location,
+ property.Name,
+ noun,
+ element.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)));
+ }
+
+ ///
+ /// The word the generator uses for the display type in its own messages.
+ ///
+ private static string GetListTypeNoun(DisplayListType listType)
+ => listType switch
+ {
+ DisplayListType.EnumCheckbox => "Checkbox",
+ DisplayListType.ComplexDropdown => "Dropdown",
+ DisplayListType.ComplexTab => "Tab",
+ DisplayListType.ComplexInline => "Inline",
+ _ => listType.ToString(),
+ };
+
+ ///
+ /// Mirrors ShokoJsonSchemaGenerator.AssertKeyUsable, which throws for anything else.
+ ///
+ private static bool IsUsableDictionaryKey(ITypeSymbol? key, KnownSymbols known)
+ {
+ if (CollectionShape.Unwrap(key) is not { } unwrapped)
+ return true;
+ if (unwrapped.SpecialType is SpecialType.System_String || unwrapped.TypeKind is TypeKind.Enum)
+ return true;
+ // [Serializable] is a metadata flag, not a stored custom attribute. The runtime synthesises
+ // the attribute back from the flag, which is what the generator reads, but the .NET
+ // targeting packs drop the flag when they emit their reference assemblies, so a type coming
+ // from one cannot be judged here. Assume such a type is fine rather than risk a false error.
+ if (unwrapped is INamedTypeSymbol { IsSerializable: true })
+ return true;
+ if (unwrapped.ContainingAssembly is { } assembly && IsReferenceAssembly(assembly))
+ return true;
+ if (known.JsonSerializableAttribute is not null && ConfigurationMembers.HasAttribute(unwrapped, known.JsonSerializableAttribute))
+ return true;
+ if (known.SerializableInterface is not null && unwrapped.AllInterfaces.Contains(known.SerializableInterface, SymbolEqualityComparer.Default))
+ return true;
+
+ return false;
+ }
+
+ private static bool IsReferenceAssembly(IAssemblySymbol assembly)
+ {
+ foreach (var attribute in assembly.GetAttributes())
+ {
+ if (attribute.AttributeClass?.ToDisplayString() is "System.Runtime.CompilerServices.ReferenceAssemblyAttribute")
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// The property's declared type syntax, falling back to the configuration type that pulls the
+ /// property in when the property itself is not declared in source.
+ ///
+ private static Location GetTypeLocation(IPropertySymbol property, INamedTypeSymbol owner, CancellationToken cancellationToken)
+ {
+ foreach (var reference in property.DeclaringSyntaxReferences)
+ {
+ if (reference.GetSyntax(cancellationToken) is PropertyDeclarationSyntax { Type: { } type })
+ return type.GetLocation();
+ }
+
+ foreach (var location in property.Locations)
+ {
+ if (location.IsInSource)
+ return location;
+ }
+
+ foreach (var location in owner.Locations)
+ {
+ if (location.IsInSource)
+ return location;
+ }
+
+ return Location.None;
+ }
+
+ ///
+ /// The Shoko.Abstractions.UI.Enums.DisplayListType values, by their underlying value.
+ ///
+ private enum DisplayListType
+ {
+ Auto = 0,
+ EnumCheckbox = 1,
+ ComplexDropdown = 2,
+ ComplexTab = 3,
+ ComplexInline = 4,
+ }
+
+ private static DisplayListType? GetListType(AttributeData attribute)
+ {
+ foreach (var argument in attribute.NamedArguments)
+ {
+ if (argument.Key is "ListType" && argument.Value.Value is int value && Enum.IsDefined(typeof(DisplayListType), value))
+ return (DisplayListType)value;
+ }
+
+ return null;
+ }
+}
diff --git a/Shoko.BuildTools.Analyzers/ConfigurationTypeIndex.cs b/Shoko.BuildTools.Analyzers/ConfigurationTypeIndex.cs
new file mode 100644
index 0000000000..2f4d02043f
--- /dev/null
+++ b/Shoko.BuildTools.Analyzers/ConfigurationTypeIndex.cs
@@ -0,0 +1,149 @@
+using System.Collections.Immutable;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+
+namespace Shoko.BuildTools.Analyzers;
+
+///
+/// The set of source types the configuration UI schema generator will walk into, computed once per
+/// compilation.
+///
+///
+///
+/// The generator starts at a type implementing Shoko.Abstractions.Config.IConfiguration or
+/// Shoko.Abstractions.Actions.IExecutableAction — an action's invocation parameters are its
+/// own settable, serialized properties, walked exactly as a configuration's are — and recurses
+/// through the property graph, so a plain class used as a section of a configuration or as a
+/// parameter of an action is analysed too. Types that only become reachable through a root in
+/// another assembly are not analysed, because that assembly runs its own copy of this analyzer.
+///
+///
+/// An action's metadata surface (Name, Description, Category,
+/// Permission, RequiresConfirmation, Scope) is not filtered out here, because
+/// every one of those is a string, a bool or an enum from a referenced assembly: none can trip any
+/// of the rules, and none embeds a source type for the walk to descend into.
+///
+///
+internal sealed class ConfigurationTypeIndex
+{
+ private readonly Lazy> _reachable;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The compilation to index.
+ /// The symbols for the current compilation.
+ public ConfigurationTypeIndex(Compilation compilation, KnownSymbols known)
+ => _reachable = new Lazy>(() => Compute(compilation, known), LazyThreadSafetyMode.ExecutionAndPublication);
+
+ ///
+ /// Whether the schema generator reaches the given type.
+ ///
+ /// The type to test.
+ /// when the type is reachable.
+ public bool Contains(INamedTypeSymbol type)
+ => _reachable.Value.Contains(type.OriginalDefinition);
+
+ private static ImmutableHashSet Compute(Compilation compilation, KnownSymbols known)
+ {
+ var reachable = ImmutableHashSet.CreateBuilder(SymbolEqualityComparer.Default);
+ var pending = new Stack();
+ foreach (var type in EnumerateSourceTypes(compilation.Assembly.GlobalNamespace))
+ {
+ if (IsRoot(type, known.Configuration) || IsRoot(type, known.ExecutableAction))
+ pending.Push(type);
+ }
+
+ while (pending.Count > 0)
+ {
+ // Indexed by original definition, so an open generic base class and every constructed
+ // form of it resolve to the same entry.
+ var type = pending.Pop().OriginalDefinition;
+ if (!reachable.Add(type))
+ continue;
+
+ if (type.BaseType is { } baseType && IsInSource(baseType))
+ pending.Push(baseType);
+
+ foreach (var member in type.GetMembers())
+ {
+ if (member is not IPropertySymbol property || !ConfigurationMembers.ReachesSchemaGenerator(property, known))
+ continue;
+
+ foreach (var embedded in EnumerateEmbeddedTypes(property.Type))
+ {
+ if (IsInSource(embedded))
+ pending.Push(embedded);
+ }
+ }
+ }
+
+ return reachable.ToImmutable();
+ }
+
+ ///
+ /// Whether the type is one the schema generator starts a walk at.
+ ///
+ /// The candidate type.
+ /// The contract to test against, or when the
+ /// compilation does not reference it.
+ /// when the type implements the contract.
+ private static bool IsRoot(INamedTypeSymbol type, INamedTypeSymbol? contract)
+ => contract is not null && type.AllInterfaces.Contains(contract, SymbolEqualityComparer.Default);
+
+ private static bool IsInSource(INamedTypeSymbol type)
+ {
+ if (type.SpecialType is not SpecialType.None || type.TypeKind is TypeKind.Enum or TypeKind.Delegate or TypeKind.Error)
+ return false;
+
+ foreach (var location in type.Locations)
+ {
+ if (location.IsInSource)
+ return true;
+ }
+
+ return false;
+ }
+
+ private static IEnumerable EnumerateSourceTypes(INamespaceOrTypeSymbol container)
+ {
+ foreach (var member in container.GetMembers())
+ {
+ switch (member)
+ {
+ case INamespaceSymbol nested:
+ foreach (var type in EnumerateSourceTypes(nested))
+ yield return type;
+ break;
+ case INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Struct } type:
+ yield return type;
+ foreach (var nestedType in EnumerateSourceTypes(type))
+ yield return nestedType;
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Yields every named type mentioned by a type reference, so List<Section> yields
+ /// both List<Section> and Section.
+ ///
+ private static IEnumerable EnumerateEmbeddedTypes(ITypeSymbol type)
+ {
+ switch (type)
+ {
+ case IArrayTypeSymbol array:
+ foreach (var embedded in EnumerateEmbeddedTypes(array.ElementType))
+ yield return embedded;
+ break;
+ case INamedTypeSymbol named:
+ yield return named;
+ foreach (var argument in named.TypeArguments)
+ {
+ foreach (var embedded in EnumerateEmbeddedTypes(argument))
+ yield return embedded;
+ }
+ break;
+ }
+ }
+}
diff --git a/Shoko.BuildTools.Analyzers/Diagnostics.cs b/Shoko.BuildTools.Analyzers/Diagnostics.cs
new file mode 100644
index 0000000000..3b8db9ae8c
--- /dev/null
+++ b/Shoko.BuildTools.Analyzers/Diagnostics.cs
@@ -0,0 +1,87 @@
+using Microsoft.CodeAnalysis;
+
+namespace Shoko.BuildTools.Analyzers;
+
+///
+/// The diagnostics reported by .
+///
+///
+/// Diagnostic IDs are stable and must never be reused for a different rule. New rules append a new
+/// number and a matching row in AnalyzerReleases.Unshipped.md.
+///
+public static class Diagnostics
+{
+ ///
+ /// The category all authoring rules are reported under. Kept as-is now that actions are analysed
+ /// too, because the ID and the category are the analyzer's contract with a consumer's severity
+ /// configuration and the rules are still about the UI schema generator.
+ ///
+ public const string Category = "Shoko.Configuration";
+
+ private const string HelpLinkPrefix = "https://docs.shokoanime.com/dev/plugin-analyzers#";
+
+ ///
+ /// A property nests a collection directly inside another collection.
+ ///
+ public static readonly DiagnosticDescriptor NestedCollection = new(
+ id: "SHOKO0001",
+ title: "Property nests a collection inside a collection",
+ messageFormat: "Property '{0}' has type '{1}', which nests a collection inside a collection. The UI schema generator keys property metadata by the property name plus a single '+List' or '+Dict' suffix, so it can only describe one level of nesting; the property will render as a flat {2} or fail schema generation outright. Wrap the inner collection in a class and use a collection of that class instead.",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "The UI schema generator appends one '+List' or '+Dict' suffix per property when it stores the UI metadata for a property. A collection directly inside another collection makes both levels claim the same key (list in list, dictionary in dictionary), or makes the intermediate level unreachable (list in dictionary, dictionary in list), so the outer level's metadata is silently dropped. A list of dictionaries additionally throws during schema generation. Introduce a class for the inner collection so each level gets its own schema.",
+ helpLinkUri: HelpLinkPrefix + "shoko0001");
+
+ ///
+ /// A property uses a dictionary key type that cannot be written as a JSON property name.
+ ///
+ public static readonly DiagnosticDescriptor UnusableDictionaryKey = new(
+ id: "SHOKO0002",
+ title: "Dictionary key is not serializable to text",
+ messageFormat: "Type '{1}' is not serializable to text and therefore cannot be used as a key in a dictionary the UI schema generator walks, but property '{0}' uses it as one. Schema generation throws, leaving the whole type without a schema and without a UI. Use 'string', an enum, a type marked with [Serializable], or a type implementing ISerializable.",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "JSON object keys are text. The UI schema generator rejects any dictionary key type that is not a string, not an enum, not marked with [Serializable] (or, on the System.Text.Json path, [JsonSerializable]) and does not implement ISerializable, by throwing while building the schema. A key type coming from a reference assembly is not reported, because reference assemblies drop the [Serializable] metadata flag and the analyzer cannot tell.",
+ helpLinkUri: HelpLinkPrefix + "shoko0002");
+
+ ///
+ /// A property's [List] display type does not match its element type.
+ ///
+ public static readonly DiagnosticDescriptor IncompatibleListType = new(
+ id: "SHOKO0003",
+ title: "List display type is not supported for these list items",
+ messageFormat: "{1} lists are not supported for non-{2} list items, but property '{0}' sets ListType to '{3}' over elements of type '{4}'. Schema generation throws, leaving the whole type without a schema and without a UI.",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "The complex list display types render one entry per class instance, so their elements have to be a class the generator renders as a section container: a type with at least one serialized property, declared outside the System and Shoko.Abstractions.UI.Components namespaces. The checkbox list display type renders one checkbox per enum member, so its elements have to be an enum.",
+ helpLinkUri: HelpLinkPrefix + "shoko0003");
+
+ ///
+ /// A complex [List] display type is used without anything to key the entries by.
+ ///
+ public static readonly DiagnosticDescriptor MissingPrimaryKey = new(
+ id: "SHOKO0004",
+ title: "Complex list display type has no primary key",
+ messageFormat: "{1} lists must have a primary key set, but neither property '{0}' nor its item type '{2}' declares a [Key] property. Schema generation throws, leaving the whole type without a schema and without a UI. Put [Key] on a property declared by '{2}' itself, or on '{0}'.",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "A complex list needs to label each entry, which it takes from a property annotated with [Key]. The generator files a property's UI metadata under its reflected type, so a [Key] inherited from a base class never reaches the derived item type's primary key scan and does not count; the same goes for a [Key] on a property either JSON serializer ignores.",
+ helpLinkUri: HelpLinkPrefix + "shoko0004");
+
+ ///
+ /// A property is rendered as a record but is not a generic dictionary.
+ ///
+ public static readonly DiagnosticDescriptor NotAGenericDictionary = new(
+ id: "SHOKO0005",
+ title: "Record-shaped property is not a generic dictionary",
+ messageFormat: "Type '{1}' does not implement IReadOnlyDictionary<,> or IDictionary<,>, but property '{0}' has that type and the schema generator renders it as a record. Schema generation throws, leaving the whole type without a schema and without a UI. Use 'Dictionary' or another generic dictionary.",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "A non-generic dictionary such as Hashtable still becomes a JSON object with additionalProperties, so the generator takes the record path and then asks the type for its key and value types. Only the generic dictionary interfaces can answer that.",
+ helpLinkUri: HelpLinkPrefix + "shoko0005");
+}
diff --git a/Shoko.BuildTools.Analyzers/KnownSymbols.cs b/Shoko.BuildTools.Analyzers/KnownSymbols.cs
new file mode 100644
index 0000000000..0f2bbde7e8
--- /dev/null
+++ b/Shoko.BuildTools.Analyzers/KnownSymbols.cs
@@ -0,0 +1,127 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+
+namespace Shoko.BuildTools.Analyzers;
+
+///
+/// The symbols needs, resolved once per compilation.
+///
+internal sealed class KnownSymbols
+{
+ ///
+ /// Shoko.Abstractions.Config.IConfiguration, if referenced.
+ ///
+ public INamedTypeSymbol? Configuration { get; }
+
+ ///
+ /// Shoko.Abstractions.Actions.IExecutableAction, if referenced.
+ ///
+ ///
+ /// An executable action's invocation parameters are its own settable, serialized properties,
+ /// walked by the very same schema generator a configuration is, so the same unrenderable shapes
+ /// break it identically.
+ ///
+ public INamedTypeSymbol? ExecutableAction { get; }
+
+ ///
+ /// Shoko.Abstractions.UI.Attributes.ListAttribute, if referenced.
+ ///
+ public INamedTypeSymbol? ListAttribute { get; }
+
+ ///
+ /// System.ComponentModel.DataAnnotations.KeyAttribute, if referenced.
+ ///
+ public INamedTypeSymbol? KeyAttribute { get; }
+
+ ///
+ /// System.Collections.IDictionary, the non-generic one.
+ ///
+ public INamedTypeSymbol? NonGenericDictionary { get; }
+
+ ///
+ /// System.Collections.Generic.IDictionary<,>.
+ ///
+ public INamedTypeSymbol? GenericDictionary { get; }
+
+ ///
+ /// System.Collections.Generic.IReadOnlyDictionary<,>.
+ ///
+ public INamedTypeSymbol? GenericReadOnlyDictionary { get; }
+
+ ///
+ /// System.Text.Json.Serialization.JsonSerializableAttribute, if referenced.
+ ///
+ public INamedTypeSymbol? JsonSerializableAttribute { get; }
+
+ ///
+ /// System.Runtime.Serialization.ISerializable.
+ ///
+ public INamedTypeSymbol? SerializableInterface { get; }
+
+ ///
+ /// Newtonsoft.Json.JsonIgnoreAttribute, if referenced.
+ ///
+ public INamedTypeSymbol? NewtonsoftJsonIgnoreAttribute { get; }
+
+ ///
+ /// System.Text.Json.Serialization.JsonIgnoreAttribute, if referenced.
+ ///
+ public INamedTypeSymbol? SystemTextJsonIgnoreAttribute { get; }
+
+ ///
+ /// NJsonSchema.Annotations.JsonSchemaIgnoreAttribute, if referenced.
+ ///
+ public INamedTypeSymbol? JsonSchemaIgnoreAttribute { get; }
+
+ ///
+ /// Types that implement a collection interface but are mapped to something other than a JSON
+ /// array or a JSON object by the schema generator, so they must not be treated as collections.
+ ///
+ public ImmutableArray NonCollectionBaseTypes { get; }
+
+ private KnownSymbols(Compilation compilation, INamedTypeSymbol? configuration, INamedTypeSymbol? executableAction)
+ {
+ Configuration = configuration;
+ ExecutableAction = executableAction;
+ ListAttribute = compilation.GetTypeByMetadataName("Shoko.Abstractions.UI.Attributes.ListAttribute");
+ KeyAttribute = compilation.GetTypeByMetadataName("System.ComponentModel.DataAnnotations.KeyAttribute");
+ NonGenericDictionary = compilation.GetTypeByMetadataName("System.Collections.IDictionary");
+ GenericDictionary = compilation.GetTypeByMetadataName("System.Collections.Generic.IDictionary`2");
+ GenericReadOnlyDictionary = compilation.GetTypeByMetadataName("System.Collections.Generic.IReadOnlyDictionary`2");
+ JsonSerializableAttribute = compilation.GetTypeByMetadataName("System.Text.Json.Serialization.JsonSerializableAttribute");
+ SerializableInterface = compilation.GetTypeByMetadataName("System.Runtime.Serialization.ISerializable");
+ NewtonsoftJsonIgnoreAttribute = compilation.GetTypeByMetadataName("Newtonsoft.Json.JsonIgnoreAttribute");
+ SystemTextJsonIgnoreAttribute = compilation.GetTypeByMetadataName("System.Text.Json.Serialization.JsonIgnoreAttribute");
+ JsonSchemaIgnoreAttribute = compilation.GetTypeByMetadataName("NJsonSchema.Annotations.JsonSchemaIgnoreAttribute");
+ NonCollectionBaseTypes = Resolve(
+ compilation,
+ "Newtonsoft.Json.Linq.JToken",
+ "System.Text.Json.Nodes.JsonNode");
+ }
+
+ ///
+ /// Resolves the symbols for the given compilation, or when the
+ /// compilation references neither contract the schema generator starts from.
+ ///
+ /// The compilation to resolve against.
+ /// The resolved symbols, or .
+ public static KnownSymbols? TryCreate(Compilation compilation)
+ {
+ var configuration = compilation.GetTypeByMetadataName("Shoko.Abstractions.Config.IConfiguration");
+ var executableAction = compilation.GetTypeByMetadataName("Shoko.Abstractions.Actions.IExecutableAction");
+ return configuration is null && executableAction is null
+ ? null
+ : new KnownSymbols(compilation, configuration, executableAction);
+ }
+
+ private static ImmutableArray Resolve(Compilation compilation, params string[] metadataNames)
+ {
+ var builder = ImmutableArray.CreateBuilder(metadataNames.Length);
+ foreach (var metadataName in metadataNames)
+ {
+ if (compilation.GetTypeByMetadataName(metadataName) is { } symbol)
+ builder.Add(symbol);
+ }
+ return builder.ToImmutable();
+ }
+}
diff --git a/Shoko.BuildTools.Analyzers/Shoko.BuildTools.Analyzers.csproj b/Shoko.BuildTools.Analyzers/Shoko.BuildTools.Analyzers.csproj
new file mode 100644
index 0000000000..c4eb96ab74
--- /dev/null
+++ b/Shoko.BuildTools.Analyzers/Shoko.BuildTools.Analyzers.csproj
@@ -0,0 +1,50 @@
+
+
+
+ 0.1.0
+
+ netstandard2.0
+ latest
+ enable
+ enable
+ Shoko.BuildTools.Analyzers
+ Shoko.BuildTools.Analyzers
+ Shoko.BuildTools.Analyzers
+ Roslyn analyzers for Shoko plugin authors. Reports configuration shapes the configuration UI schema generator cannot render.
+ true
+ true
+ true
+
+ false
+ true
+ true
+ true
+ $(TargetsForTfmSpecificContentInPackage);_ShokoPackAnalyzer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Shoko.Server.sln b/Shoko.Server.sln
index f08e77f1ff..6cc421a338 100644
--- a/Shoko.Server.sln
+++ b/Shoko.Server.sln
@@ -112,6 +112,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shoko.BuildTools", "Shoko.B
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shoko.BuildTools.Targets", "Shoko.BuildTools.Targets\Shoko.BuildTools.Targets.csproj", "{391720CB-A745-4967-A67B-F8D9C0DAB0C6}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shoko.BuildTools.Analyzers", "Shoko.BuildTools.Analyzers\Shoko.BuildTools.Analyzers.csproj", "{3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -391,6 +393,30 @@ Global
{391720CB-A745-4967-A67B-F8D9C0DAB0C6}.Benchmarks|x64.Build.0 = Debug|Any CPU
{391720CB-A745-4967-A67B-F8D9C0DAB0C6}.Benchmarks|x86.ActiveCfg = Debug|Any CPU
{391720CB-A745-4967-A67B-F8D9C0DAB0C6}.Benchmarks|x86.Build.0 = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Debug|x64.Build.0 = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Debug|x86.Build.0 = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Release|x64.ActiveCfg = Release|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Release|x64.Build.0 = Release|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Release|x86.ActiveCfg = Release|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Release|x86.Build.0 = Release|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.ApiLogging|Any CPU.ActiveCfg = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.ApiLogging|Any CPU.Build.0 = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.ApiLogging|x64.ActiveCfg = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.ApiLogging|x64.Build.0 = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.ApiLogging|x86.ActiveCfg = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.ApiLogging|x86.Build.0 = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Benchmarks|Any CPU.ActiveCfg = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Benchmarks|Any CPU.Build.0 = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Benchmarks|x64.ActiveCfg = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Benchmarks|x64.Build.0 = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Benchmarks|x86.ActiveCfg = Debug|Any CPU
+ {3BB05FAF-EFA5-4525-9AF1-AEF60D6CED60}.Benchmarks|x86.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Shoko.Server/API/v3/Controllers/ActionController.cs b/Shoko.Server/API/v3/Controllers/ActionController.cs
index 132f560ef1..032c244ad1 100644
--- a/Shoko.Server/API/v3/Controllers/ActionController.cs
+++ b/Shoko.Server/API/v3/Controllers/ActionController.cs
@@ -6,10 +6,13 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging;
+using Newtonsoft.Json.Linq;
using Shoko.Abstractions.Actions;
using Shoko.Abstractions.Metadata.Enums;
using Shoko.Abstractions.Metadata.Services;
+using Shoko.Abstractions.UI;
using Shoko.Abstractions.Video.Services;
using Shoko.QueueProcessor.Abstractions;
using Shoko.QueueProcessor.Scheduling;
@@ -90,20 +93,60 @@ public ActionResult> GetActions([FromQuery] ActionScope?
=> Ok(_actionService.GetActions(scope, User.IsAdmin == 1 ? null : ActionPermission.User)
.Select(ActionInfo.FromExecutableActionInfo));
+ ///
+ /// Get a render-ready UI definition for the parameters of the action with
+ /// the given ID, in the same shape a configuration editor is described by.
+ ///
+ ///
+ /// One endpoint covers every scope: an action's parameters come off the
+ /// action type, which does not vary by the entity it is invoked against,
+ /// so a series-scoped action is described here the same as a global one.
+ /// Ask only when the listing said .
+ ///
+ /// Action ID.
+ /// The UI definition for the action's parameters.
+ [HttpGet("{actionID:guid}/UiDefinition")]
+ public ActionResult GetActionUiDefinition([FromRoute] Guid actionID)
+ {
+ if (_actionService.GetActionInfo(actionID) is not { } info)
+ return NotFound("Action not found.");
+
+ if (info.Parameters is not { } parameters)
+ return NotFound("Action does not take any parameters.");
+
+ return parameters;
+ }
+
///
/// Invoke a global action by its ID. Returns 200 (accepted), or 400 with
/// a reason when the action's validation (or the caller's permission)
/// rejects the invocation.
///
/// Action ID.
+ ///
+ /// Optional. The action's invocation parameters. Omit the body entirely
+ /// for an action that takes none.
+ ///
/// Cancellation token.
[HttpPost("{actionID:guid}")]
- public async Task Invoke([FromRoute] Guid actionID, CancellationToken token)
+ public async Task Invoke(
+ [FromRoute] Guid actionID,
+ [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] JObject? parameters,
+ CancellationToken token
+ )
{
if (_actionService.GetActionInfo(actionID) is null)
return NotFound("Action not found.");
- var validation = await _actionService.InvokeAsync(actionID, User, token);
+ if (_actionService.ValidateParameters(actionID, parameters) is { Count: > 0 } errors)
+ return ValidationProblem(errors);
+
+ // No body takes the same overload it always has, so an action that
+ // declares no parameters is invoked exactly as before.
+ var parameterMap = parameters.ToParameters();
+ var validation = parameterMap is null
+ ? await _actionService.InvokeAsync(actionID, User, token)
+ : await _actionService.InvokeAsync(actionID, parameterMap, User, token);
return validation is null ? Ok() : BadRequest(validation.Reason);
}
diff --git a/Shoko.Server/API/v3/Controllers/ConfigurationController.cs b/Shoko.Server/API/v3/Controllers/ConfigurationController.cs
index 5ce9268b46..8370167dfe 100644
--- a/Shoko.Server/API/v3/Controllers/ConfigurationController.cs
+++ b/Shoko.Server/API/v3/Controllers/ConfigurationController.cs
@@ -12,11 +12,13 @@
using Shoko.Abstractions.Config.Exceptions;
using Shoko.Abstractions.Config.Services;
using Shoko.Abstractions.Plugin;
+using Shoko.Abstractions.UI;
using Shoko.Abstractions.Web.Attributes;
using Shoko.Server.API.Annotations;
using Shoko.Server.API.v3.Models.Common;
using Shoko.Server.API.v3.Models.Configuration;
using Shoko.Server.Plugin;
+using Shoko.Server.Services.Configuration;
using Shoko.Server.Settings;
using Shoko.Server.Utilities;
@@ -314,6 +316,29 @@ public ActionResult SchemaConfiguration(Guid configID)
return Content(configurationService.GetSchema(configInfo), "application/json");
}
+ ///
+ /// Proof of concept. Get a render-ready UI definition for
+ /// the configuration with the given id.
+ ///
+ ///
+ /// Unlike /Schema, the returned document is meant to be sufficient
+ /// on its own: every element carries a concrete element kind, its label,
+ /// its default and the constraints needed for a cheap client-side
+ /// pre-check. The schema remains the authority for server-side
+ /// validation, and still carries the x-uiDefinition bag the
+ /// configuration validator reads back.
+ ///
+ /// Configuration id
+ /// The UI definition for the configuration.
+ [HttpGet("{configID:guid}/UiDefinition")]
+ public ActionResult GetConfigurationUiDefinition(Guid configID)
+ {
+ if (configurationService.GetConfigurationInfo(configID) is not { } configInfo)
+ return NotFound($"Configuration '{configID}' not found!");
+
+ return configInfo.UiDefinition;
+ }
+
///
/// Gets the default configuration object for the configuration with the
/// given id. Returns a fresh configuration populated with defaults, as
diff --git a/Shoko.Server/API/v3/Controllers/EpisodeActionController.cs b/Shoko.Server/API/v3/Controllers/EpisodeActionController.cs
index e1be617662..a590691f7c 100644
--- a/Shoko.Server/API/v3/Controllers/EpisodeActionController.cs
+++ b/Shoko.Server/API/v3/Controllers/EpisodeActionController.cs
@@ -4,7 +4,10 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Newtonsoft.Json.Linq;
using Shoko.Server.API.Annotations;
+using Shoko.Server.API.v3.Models.Action;
using Shoko.Server.Repositories.Cached;
using Shoko.Server.Settings;
using Shoko.Server.Services;
@@ -26,11 +29,16 @@ public class EpisodeActionController(ActionService actionService, AnimeEpisodeRe
///
/// Episode ID.
/// Action ID.
+ ///
+ /// Optional. The action's invocation parameters. Omit the body entirely
+ /// for an action that takes none.
+ ///
/// Cancellation token.
[HttpPost("{actionID:guid}")]
public async Task Invoke(
[FromRoute, Range(1, int.MaxValue)] int episodeID,
[FromRoute] Guid actionID,
+ [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] JObject? parameters,
CancellationToken token
)
{
@@ -41,7 +49,15 @@ CancellationToken token
if (episodeEntity is null)
return NotFound("Episode not found.");
- var validation = await actionService.InvokeAsync(actionID, episodeEntity, User, token);
+ if (actionService.ValidateParameters(actionID, parameters) is { Count: > 0 } errors)
+ return ValidationProblem(errors);
+
+ // No body takes the same overload it always has, so an action that
+ // declares no parameters is invoked exactly as before.
+ var parameterMap = parameters.ToParameters();
+ var validation = parameterMap is null
+ ? await actionService.InvokeAsync(actionID, episodeEntity, User, token)
+ : await actionService.InvokeAsync(actionID, episodeEntity, parameterMap, User, token);
return validation is null ? Ok() : BadRequest(validation.Reason);
}
}
diff --git a/Shoko.Server/API/v3/Controllers/FileActionController.cs b/Shoko.Server/API/v3/Controllers/FileActionController.cs
index 5e508dc334..e252490de2 100644
--- a/Shoko.Server/API/v3/Controllers/FileActionController.cs
+++ b/Shoko.Server/API/v3/Controllers/FileActionController.cs
@@ -4,7 +4,10 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Newtonsoft.Json.Linq;
using Shoko.Server.API.Annotations;
+using Shoko.Server.API.v3.Models.Action;
using Shoko.Server.Repositories.Cached;
using Shoko.Server.Settings;
using Shoko.Server.Services;
@@ -26,11 +29,16 @@ public class FileActionController(ActionService actionService, VideoLocalReposit
///
/// File ID.
/// Action ID.
+ ///
+ /// Optional. The action's invocation parameters. Omit the body entirely
+ /// for an action that takes none.
+ ///
/// Cancellation token.
[HttpPost("{actionID:guid}")]
public async Task Invoke(
[FromRoute, Range(1, int.MaxValue)] int fileID,
[FromRoute] Guid actionID,
+ [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] JObject? parameters,
CancellationToken token
)
{
@@ -41,7 +49,15 @@ CancellationToken token
if (videoEntity is null)
return NotFound("File not found.");
- var validation = await actionService.InvokeAsync(actionID, videoEntity, User, token);
+ if (actionService.ValidateParameters(actionID, parameters) is { Count: > 0 } errors)
+ return ValidationProblem(errors);
+
+ // No body takes the same overload it always has, so an action that
+ // declares no parameters is invoked exactly as before.
+ var parameterMap = parameters.ToParameters();
+ var validation = parameterMap is null
+ ? await actionService.InvokeAsync(actionID, videoEntity, User, token)
+ : await actionService.InvokeAsync(actionID, videoEntity, parameterMap, User, token);
return validation is null ? Ok() : BadRequest(validation.Reason);
}
}
diff --git a/Shoko.Server/API/v3/Controllers/GroupActionController.cs b/Shoko.Server/API/v3/Controllers/GroupActionController.cs
index 10e35613c2..ae3a9bee9e 100644
--- a/Shoko.Server/API/v3/Controllers/GroupActionController.cs
+++ b/Shoko.Server/API/v3/Controllers/GroupActionController.cs
@@ -4,7 +4,10 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Newtonsoft.Json.Linq;
using Shoko.Server.API.Annotations;
+using Shoko.Server.API.v3.Models.Action;
using Shoko.Server.Repositories.Cached;
using Shoko.Server.Settings;
using Shoko.Server.Services;
@@ -26,11 +29,16 @@ public class GroupActionController(ActionService actionService, AnimeGroupReposi
///
/// Group ID.
/// Action ID.
+ ///
+ /// Optional. The action's invocation parameters. Omit the body entirely
+ /// for an action that takes none.
+ ///
/// Cancellation token.
[HttpPost("{actionID:guid}")]
public async Task Invoke(
[FromRoute, Range(1, int.MaxValue)] int groupID,
[FromRoute] Guid actionID,
+ [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] JObject? parameters,
CancellationToken token
)
{
@@ -41,7 +49,15 @@ CancellationToken token
if (groupEntity is null)
return NotFound("Group not found.");
- var validation = await actionService.InvokeAsync(actionID, groupEntity, User, token);
+ if (actionService.ValidateParameters(actionID, parameters) is { Count: > 0 } errors)
+ return ValidationProblem(errors);
+
+ // No body takes the same overload it always has, so an action that
+ // declares no parameters is invoked exactly as before.
+ var parameterMap = parameters.ToParameters();
+ var validation = parameterMap is null
+ ? await actionService.InvokeAsync(actionID, groupEntity, User, token)
+ : await actionService.InvokeAsync(actionID, groupEntity, parameterMap, User, token);
return validation is null ? Ok() : BadRequest(validation.Reason);
}
}
diff --git a/Shoko.Server/API/v3/Controllers/SeriesActionController.cs b/Shoko.Server/API/v3/Controllers/SeriesActionController.cs
index d068f338af..830f99f69c 100644
--- a/Shoko.Server/API/v3/Controllers/SeriesActionController.cs
+++ b/Shoko.Server/API/v3/Controllers/SeriesActionController.cs
@@ -4,7 +4,10 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Newtonsoft.Json.Linq;
using Shoko.Server.API.Annotations;
+using Shoko.Server.API.v3.Models.Action;
using Shoko.Server.Repositories.Cached;
using Shoko.Server.Settings;
using Shoko.Server.Services;
@@ -26,11 +29,16 @@ public class SeriesActionController(ActionService actionService, AnimeSeriesRepo
///
/// Series ID.
/// Action ID.
+ ///
+ /// Optional. The action's invocation parameters. Omit the body entirely
+ /// for an action that takes none.
+ ///
/// Cancellation token.
[HttpPost("{actionID:guid}")]
public async Task Invoke(
[FromRoute, Range(1, int.MaxValue)] int seriesID,
[FromRoute] Guid actionID,
+ [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] JObject? parameters,
CancellationToken token
)
{
@@ -41,7 +49,15 @@ CancellationToken token
if (seriesEntity is null)
return NotFound("Series not found.");
- var validation = await actionService.InvokeAsync(actionID, seriesEntity, User, token);
+ if (actionService.ValidateParameters(actionID, parameters) is { Count: > 0 } errors)
+ return ValidationProblem(errors);
+
+ // No body takes the same overload it always has, so an action that
+ // declares no parameters is invoked exactly as before.
+ var parameterMap = parameters.ToParameters();
+ var validation = parameterMap is null
+ ? await actionService.InvokeAsync(actionID, seriesEntity, User, token)
+ : await actionService.InvokeAsync(actionID, seriesEntity, parameterMap, User, token);
return validation is null ? Ok() : BadRequest(validation.Reason);
}
}
diff --git a/Shoko.Server/API/v3/Models/Action/ActionInfo.cs b/Shoko.Server/API/v3/Models/Action/ActionInfo.cs
index 92cd2445af..fe90536ebd 100644
--- a/Shoko.Server/API/v3/Models/Action/ActionInfo.cs
+++ b/Shoko.Server/API/v3/Models/Action/ActionInfo.cs
@@ -66,6 +66,18 @@ public class ActionInfo
///
public string? ConfirmationMessage { get; set; }
+ ///
+ /// Whether the action takes invocation parameters, and therefore has a
+ /// parameter form to render before it can be invoked.
+ ///
+ ///
+ /// Only the flag is carried here. The definition itself is a tree that
+ /// can run to tens of kilobytes, and a listing returns every action the
+ /// caller may invoke, so sending one per row would dwarf the listing.
+ ///
+ [Required]
+ public bool HasParameters { get; set; }
+
///
/// Maps a registered action to its API representation.
///
@@ -80,5 +92,6 @@ public class ActionInfo
Permission = info.Permission,
RequiresConfirmation = info.RequiresConfirmation,
ConfirmationMessage = info.ConfirmationMessage,
+ HasParameters = info.Parameters is not null,
};
}
diff --git a/Shoko.Server/API/v3/Models/Action/ActionParameterBody.cs b/Shoko.Server/API/v3/Models/Action/ActionParameterBody.cs
new file mode 100644
index 0000000000..39f05b7c3c
--- /dev/null
+++ b/Shoko.Server/API/v3/Models/Action/ActionParameterBody.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json.Linq;
+
+namespace Shoko.Server.API.v3.Models.Action;
+
+///
+/// Turns an invocation endpoint's optional JSON body into the parameter
+/// dictionary the action service takes.
+///
+///
+/// The five invoke endpoints each resolve a different scope entity, so they
+/// cannot share a single action method, but the body handling is identical
+/// and lives here rather than five times over.
+///
+public static class ActionParameterBody
+{
+ ///
+ /// Converts a request body into invocation parameters.
+ ///
+ ///
+ /// Values stay as s. The service serialises the
+ /// dictionary straight back to JSON before populating the action, so
+ /// keeping the parsed tokens avoids a lossy trip through CLR primitives.
+ ///
+ ///
+ /// The request body, or when the caller sent none.
+ ///
+ ///
+ /// The parameters, or when there was no body —
+ /// which is how every action was invoked before parameters existed, and
+ /// how a parameterless one still is.
+ ///
+ public static IReadOnlyDictionary? ToParameters(this JObject? body)
+ => body is null ? null : body.Properties().ToDictionary(x => x.Name, x => (object?)x.Value);
+}
diff --git a/Shoko.Server/API/v3/Models/Configuration/ConfigurationActionResultMessage.cs b/Shoko.Server/API/v3/Models/Configuration/ConfigurationActionResultMessage.cs
index 4bc6039eeb..b5e9f589a7 100644
--- a/Shoko.Server/API/v3/Models/Configuration/ConfigurationActionResultMessage.cs
+++ b/Shoko.Server/API/v3/Models/Configuration/ConfigurationActionResultMessage.cs
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Enums;
using AbstractConfigurationActionResultMessage = Shoko.Abstractions.Config.ConfigurationActionResultMessage;
diff --git a/Shoko.Server/Renamer/WebAOMSettings.cs b/Shoko.Server/Renamer/WebAOMSettings.cs
index 61c2089a86..e18d22b104 100644
--- a/Shoko.Server/Renamer/WebAOMSettings.cs
+++ b/Shoko.Server/Renamer/WebAOMSettings.cs
@@ -1,9 +1,9 @@
using System.ComponentModel.DataAnnotations;
using Shoko.Abstractions.Config;
-using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
using Shoko.Abstractions.Config.Services;
using Shoko.Abstractions.Plugin;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Renamer;
diff --git a/Shoko.Server/Services/ActionService.cs b/Shoko.Server/Services/ActionService.cs
index 675e09369d..41f152fa51 100644
--- a/Shoko.Server/Services/ActionService.cs
+++ b/Shoko.Server/Services/ActionService.cs
@@ -7,8 +7,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using NJsonSchema;
using Shoko.Abstractions.Actions;
using Shoko.Abstractions.Actions.Services;
+using Shoko.Abstractions.Config.Services;
using Shoko.Abstractions.Extensions;
using Shoko.Abstractions.Metadata.Anidb.Enums;
using Shoko.Abstractions.Metadata.Anidb.Services;
@@ -33,6 +36,7 @@
using Shoko.Server.Scheduling.Jobs.Actions;
using Shoko.Server.Scheduling.Jobs.AniDB;
using Shoko.Server.Scheduling.Jobs.Shoko;
+using Shoko.Server.Services.Configuration;
using Shoko.Server.Settings;
namespace Shoko.Server.Services;
@@ -67,6 +71,10 @@ public class ActionService : IActionService
private readonly IServiceProvider _services;
+ private readonly ActionUiDefinitionBuilder _actionUiDefinitionBuilder;
+
+ private readonly IConfigurationService _configurationService;
+
///
/// Registered action types and their metadata. Populated once during
/// . A fresh transient instance is resolved from
@@ -80,7 +88,13 @@ public class ActionService : IActionService
/// so the abstraction surface never
/// leaks server internals.
///
- private sealed record RegisteredAction(ExecutableActionInfo Info, Type ActionType);
+ /// The metadata exposed to plugins.
+ /// The concrete action type.
+ ///
+ /// The schema an invocation payload is checked against, or
+ /// when the action declares no parameters.
+ ///
+ private sealed record RegisteredAction(ExecutableActionInfo Info, Type ActionType, JsonSchema? ParameterSchema);
private readonly VideoLocalRepository _videoLocals;
@@ -123,6 +137,8 @@ public ActionService(
IPluginPackageManager pluginPackageManager,
IPluginManager pluginManager,
IServiceProvider services,
+ ActionUiDefinitionBuilder actionUiDefinitionBuilder,
+ IConfigurationService configurationService,
VideoLocalRepository videoLocals,
VideoLocal_PlaceRepository videoLocalPlaces,
StoredReleaseInfoRepository storedReleaseInfos,
@@ -152,6 +168,8 @@ AniDB_Anime_RelationRepository anidbAnimeRelations
_pluginPackageManager = pluginPackageManager;
_pluginManager = pluginManager;
_services = services;
+ _actionUiDefinitionBuilder = actionUiDefinitionBuilder;
+ _configurationService = configurationService;
_videoLocals = videoLocals;
_videoLocalPlaces = videoLocalPlaces;
_storedReleaseInfos = storedReleaseInfos;
@@ -232,6 +250,11 @@ public void AddParts(IEnumerable<(Guid PluginId, Type ActionType)> discoveredAct
? _pluginManager.GetPluginInfo(pluginId)?.Name ?? actionType.Assembly.GetName().Name!
: probe.Category.ToString();
+ // The action's parameters are its own settable, serialized
+ // properties, described the same way a configuration is. Null when
+ // the action declares none.
+ var parameters = _actionUiDefinitionBuilder.Build(id, probe.Name, probe.Description, actionType);
+
_actions[id] = new RegisteredAction(new ExecutableActionInfo(
id,
probe.Name,
@@ -242,8 +265,9 @@ public void AddParts(IEnumerable<(Guid PluginId, Type ActionType)> discoveredAct
probe.Permission,
probe.RequiresConfirmation,
probe.ConfirmationMessage,
- pluginId
- ), actionType);
+ pluginId,
+ parameters?.Definition
+ ), actionType, parameters?.Schema);
}
}
@@ -292,7 +316,61 @@ internal static void PopulateParameters(IExecutableAction action, IReadOnlyDicti
if (parameters is not { Count: > 0 })
return;
- JsonConvert.PopulateObject(JsonConvert.SerializeObject(parameters), action);
+ JsonConvert.PopulateObject(JsonConvert.SerializeObject(parameters), action, _populateSettings);
+ }
+
+ ///
+ /// The action's own metadata is hidden from population as well as from
+ /// the schema, so a payload naming Name or Permission cannot
+ /// write to the instance even if it somehow reaches here unvalidated.
+ ///
+ private static readonly JsonSerializerSettings _populateSettings = new()
+ {
+ ContractResolver = new ActionMetadataContractResolver(),
+ };
+
+ ///
+ /// Checks an invocation payload against the action's parameter schema.
+ ///
+ ///
+ ///
+ /// Only the API boundary calls this. An in-process caller passes a typed
+ /// dictionary it built in code rather than a document it parsed, and the
+ /// failure it wants is a compiler error, not a dictionary of paths — so
+ ///
+ /// stays free of it.
+ ///
+ ///
+ /// The errors come back keyed by property path, which is the shape the
+ /// configuration endpoints already return for a rejected body.
+ ///
+ ///
+ /// The action being invoked.
+ ///
+ /// The payload, or when the caller sent no body.
+ ///
+ /// Errors per property path; empty when the payload is acceptable.
+ public IReadOnlyDictionary> ValidateParameters(Guid actionId, JObject? parameters)
+ {
+ // No body is how every action has always been invoked, and how one that
+ // takes no parameters still is. There is nothing to check.
+ if (parameters is null)
+ return new Dictionary>();
+
+ if (!_actions.TryGetValue(actionId, out var registered))
+ throw new KeyNotFoundException($"No action registered for {actionId}");
+
+ if (registered.ParameterSchema is not { } schema)
+ {
+ return parameters.Count is 0
+ ? new Dictionary>()
+ : new Dictionary>
+ {
+ [string.Empty] = [$"The action '{registered.Info.Name}' does not take any parameters."],
+ };
+ }
+
+ return _configurationService.Validate(parameters.ToString(Formatting.None), schema);
}
///
diff --git a/Shoko.Server/Services/Configuration/ActionMetadataContractResolver.cs b/Shoko.Server/Services/Configuration/ActionMetadataContractResolver.cs
new file mode 100644
index 0000000000..cd258fd9f5
--- /dev/null
+++ b/Shoko.Server/Services/Configuration/ActionMetadataContractResolver.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Newtonsoft.Json.Serialization;
+using Shoko.Abstractions.Actions;
+
+namespace Shoko.Server.Services.Configuration;
+
+///
+/// Hides an executable action's own metadata surface from the schema
+/// generator, so only the action's invocation parameters are described.
+///
+///
+///
+/// A configuration is described by every settable, serialized property it
+/// has, and an action's parameters work the same way — the caller's payload
+/// is populated straight onto the action instance. An action instance also
+/// carries its metadata as ordinary public properties though, and the
+/// configuration rule would sweep Name, Description,
+/// Category, Permission, RequiresConfirmation,
+/// ConfirmationMessage and Scope in as if the caller were
+/// meant to supply them.
+///
+///
+/// The excluded set is derived by reflection over
+/// and the four scoped base classes rather
+/// than written out, so it cannot drift from the contract, and a plugin
+/// author never has to annotate a parameter to opt it in.
+///
+///
+/// Both the name and the type have to match before a property is dropped,
+/// so an action whose parameter merely happens to be called Name
+/// keeps it as long as it is not the
+/// implementation itself.
+///
+///
+/// The scoped context (SeriesAction.Series and friends) needs no
+/// entry: it is a property, and Newtonsoft only
+/// considers public members, so it never reaches the generator in the first
+/// place. IScopedAction.SetContext is likewise both a method and an
+/// explicit implementation of an interface this assembly cannot even name.
+///
+///
+internal sealed class ActionMetadataContractResolver : DefaultContractResolver
+{
+ ///
+ /// The scoped base classes, which add Scope on top of what
+ /// declares.
+ ///
+ private static readonly Type[] _scopedBaseTypes = [typeof(SeriesAction), typeof(GroupAction), typeof(EpisodeAction), typeof(VideoAction)];
+
+ ///
+ /// The metadata surface, as property name to declared type.
+ ///
+ internal static readonly IReadOnlyDictionary MetadataMembers = typeof(IExecutableAction)
+ .GetProperties()
+ .Concat(_scopedBaseTypes.SelectMany(x => x.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)))
+ .GroupBy(x => x.Name, StringComparer.Ordinal)
+ .ToDictionary(x => x.Key, x => x.First().PropertyType, StringComparer.Ordinal);
+
+ ///
+ protected override List GetSerializableMembers(Type objectType)
+ {
+ var members = base.GetSerializableMembers(objectType);
+
+ // Only the action itself carries the metadata surface; a parameter's own
+ // type is walked by the ordinary configuration rules, so a nested class
+ // with a `Name` on it keeps it.
+ if (!objectType.IsAssignableTo(typeof(IExecutableAction)))
+ return members;
+
+ return members.Where(x => !IsMetadataMember(x)).ToList();
+ }
+
+ private static bool IsMetadataMember(MemberInfo member)
+ => member is PropertyInfo property &&
+ MetadataMembers.TryGetValue(property.Name, out var declaredType) &&
+ property.PropertyType == declaredType;
+}
diff --git a/Shoko.Server/Services/Configuration/ActionUiDefinitionBuilder.cs b/Shoko.Server/Services/Configuration/ActionUiDefinitionBuilder.cs
new file mode 100644
index 0000000000..3ef1452aab
--- /dev/null
+++ b/Shoko.Server/Services/Configuration/ActionUiDefinitionBuilder.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Linq;
+using System.Reflection;
+using Microsoft.Extensions.Logging;
+using NJsonSchema;
+using Shoko.Abstractions.Actions;
+using Shoko.Abstractions.UI;
+
+namespace Shoko.Server.Services.Configuration;
+
+///
+/// How an executable action's invocation parameters are described: a
+/// render-ready definition for a client to lay a form out from, and the JSON
+/// schema an incoming payload is checked against.
+///
+/// The render-ready definition.
+/// The schema an invocation payload must satisfy.
+public sealed record ActionParameterDescription(UiDefinition Definition, JsonSchema Schema);
+
+///
+/// Produces the render-ready for an executable
+/// action's invocation parameters.
+///
+///
+///
+/// Nothing here re-implements the configuration machinery: the schema comes
+/// out of
+/// and the definition out of the same a
+/// configuration goes through, so the two produce the same DTO and a client
+/// cannot tell them apart.
+///
+///
+/// It owns its own generator instance rather than sharing
+/// ConfigurationService's, because the generator serialises every
+/// walk behind a single lock and action registration happens while
+/// configurations are still being described.
+///
+///
+/// Logger factory.
+public class ActionUiDefinitionBuilder(ILoggerFactory loggerFactory)
+{
+ private readonly ShokoJsonSchemaGenerator _generator =
+ new(ShokoJsonSerializers.CreateNewtonsoftSettings(), ShokoJsonSerializers.CreateSystemTextJsonOptions());
+
+ private readonly UiDefinitionBuilder _uiDefinitionBuilder = new(loggerFactory.CreateLogger());
+
+
+ ///
+ /// Describes an action's invocation parameters.
+ ///
+ ///
+ /// An action that declares parameters has to be describable. A shape the
+ /// generator cannot render is a defect in the action, not a condition to
+ /// recover from, so it fails startup exactly as the equivalent
+ /// configuration would rather than leaving a half-usable action behind
+ /// with no way to invoke it from a UI. The SHOKO0001-0005 analyzer rules
+ /// catch these shapes at compile time for anyone referencing the package.
+ ///
+ /// The action's id.
+ /// The action's display name.
+ /// The action's description.
+ /// The concrete action type.
+ ///
+ /// The description, or when the action declares no
+ /// parameters.
+ ///
+ ///
+ /// Thrown when the action declares parameters that cannot be described.
+ ///
+ public ActionParameterDescription? Build(Guid id, string name, string? description, Type actionType)
+ {
+ ArgumentNullException.ThrowIfNull(actionType);
+
+ // Generating a schema is not free and the common case is an action with
+ // no parameters at all, so skip the walk when reflection can already
+ // tell there is nothing but metadata on the type.
+ if (!MayHaveParameters(actionType))
+ return null;
+
+ var wrapped = _generator.GetSchemaForActionParameters(actionType);
+ if (wrapped.Schema.ActualProperties.Count is 0)
+ return null;
+
+ return new(_uiDefinitionBuilder.Build(id, name, description, wrapped), wrapped.Schema);
+ }
+
+ ///
+ /// Whether the action carries any public property beyond its metadata
+ /// surface.
+ ///
+ ///
+ /// Deliberately permissive — it only has to be free of false negatives,
+ /// since the generated schema is the thing that actually decides. A
+ /// get-only collection counts, because Newtonsoft populates one.
+ ///
+ private static bool MayHaveParameters(Type actionType)
+ => actionType
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Any(x => !ActionMetadataContractResolver.MetadataMembers.TryGetValue(x.Name, out var declaredType) || x.PropertyType != declaredType);
+}
diff --git a/Shoko.Server/Services/Configuration/ConfigurationService.cs b/Shoko.Server/Services/Configuration/ConfigurationService.cs
index fa6ba5f139..2e02a04f2c 100644
--- a/Shoko.Server/Services/Configuration/ConfigurationService.cs
+++ b/Shoko.Server/Services/Configuration/ConfigurationService.cs
@@ -10,12 +10,12 @@
using System.Threading.Tasks;
using Force.DeepCloner;
using Microsoft.Extensions.Logging;
+using NJsonSchema;
+using NJsonSchema.Validation;
using Namotion.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
-using NJsonSchema;
-using NJsonSchema.Validation;
using Shoko.Abstractions.Config;
using Shoko.Abstractions.Config.Attributes;
using Shoko.Abstractions.Config.Enums;
@@ -25,6 +25,9 @@
using Shoko.Abstractions.Extensions;
using Shoko.Abstractions.Plugin;
using Shoko.Abstractions.Plugin.Models;
+using Shoko.Abstractions.UI;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
using Shoko.Abstractions.User;
using Shoko.Abstractions.Utilities;
using Shoko.Server.Extensions;
@@ -53,8 +56,12 @@ public partial class ConfigurationService : IConfigurationService
private readonly ConcurrentDictionary _configurationTypes = [];
+ private readonly UiDefinitionBuilder _uiDefinitionBuilder;
+
private readonly ConcurrentDictionary _serializedSchemas = [];
+ private readonly ConcurrentDictionary _wrappedSchemas = [];
+
private readonly ConcurrentDictionary _loadedConfigurations = [];
private readonly ConcurrentDictionary _savedMemoryConfigurations = [];
@@ -79,27 +86,14 @@ public ConfigurationService(ILoggerFactory loggerFactory, IApplicationPaths appl
_loggerFactory = loggerFactory;
_applicationPaths = applicationPaths;
_pluginManager = pluginManager;
- _newtonsoftJsonSerializerSettings = new()
- {
- Formatting = Formatting.Indented,
- ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
- DefaultValueHandling = DefaultValueHandling.Include,
- ObjectCreationHandling = ObjectCreationHandling.Replace,
- MissingMemberHandling = MissingMemberHandling.Ignore,
- Converters = [new StringEnumConverter()]
- };
- _systemTextJsonSerializerOptions = new()
- {
- AllowTrailingCommas = true,
- WriteIndented = true,
- PreferredObjectCreationHandling = JsonObjectCreationHandling.Replace,
- ReferenceHandler = ReferenceHandler.IgnoreCycles,
- };
- _systemTextJsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
+ _newtonsoftJsonSerializerSettings = ShokoJsonSerializers.CreateNewtonsoftSettings();
+ _systemTextJsonSerializerOptions = ShokoJsonSerializers.CreateSystemTextJsonOptions();
_jsonSchemaGenerator = new(_newtonsoftJsonSerializerSettings, _systemTextJsonSerializerOptions);
+ _uiDefinitionBuilder = new(loggerFactory.CreateLogger());
var wrappedSchema = _jsonSchemaGenerator.GetSchemaForType(typeof(ServerSettings));
wrappedSchema.Schema.Id = GetID(typeof(ServerSettings)).ToString();
+ _wrappedSchemas[Guid.Empty] = wrappedSchema;
_configurationTypes[Guid.Empty] = new(this)
{
// We first map the server settings to the empty guid because the id
@@ -156,6 +150,8 @@ public void AddParts(IEnumerable configurationTypes)
_configurationTypes.TryRemove(Guid.Empty, out _);
_serializedSchemas[_configurationTypes[serverSettingsID]] = _serializedSchemas[serverSettingsInfo];
_serializedSchemas.TryRemove(serverSettingsInfo, out _);
+ _wrappedSchemas[serverSettingsID] = _wrappedSchemas[Guid.Empty];
+ _wrappedSchemas.TryRemove(Guid.Empty, out _);
_loadedConfigurations[serverSettingsID] = _loadedConfigurations[Guid.Empty];
_loadedConfigurations.TryRemove(Guid.Empty, out _);
if (InternalLoadedEnvironmentVariables.TryGetValue(Guid.Empty, out var envVarDict))
@@ -174,6 +170,7 @@ public void AddParts(IEnumerable configurationTypes)
var contextualType = configurationType.ToContextualType();
var wrappedSchema = _jsonSchemaGenerator.GetSchemaForType(configurationType);
wrappedSchema.Schema.Id = id.ToString();
+ _wrappedSchemas[id] = wrappedSchema;
var description = TypeReflectionExtensions.GetDescription(contextualType);
var name = wrappedSchema.Schema.Title!;
string? path = null;
@@ -999,6 +996,29 @@ public JsonSchema GenerateSchema(Type type)
return wrappedSchema.Schema;
}
+ ///
+ /// Deliberately stateless and keyed on nothing: the type does not have to
+ /// be a registered configuration, so there is no configuration identity
+ /// to cache against and a plugin calling this with a form model of its own
+ /// must not populate a cache keyed on one.
+ /// holds the per-configuration
+ /// cache instead.
+ ///
+ public UiDefinition GenerateUiDefinition(Type type)
+ {
+ ArgumentNullException.ThrowIfNull(type);
+
+ var id = GetID(type);
+ var wrappedSchema = _jsonSchemaGenerator.GetSchemaForType(type);
+ wrappedSchema.Schema.Id = id.ToString();
+ // Both come off the type the same way `AddParts` derives them for a
+ // configuration, so a registered configuration is described identically
+ // whichever way it is reached.
+ var name = wrappedSchema.Schema.Title ?? type.Name;
+ var description = TypeReflectionExtensions.GetDescription(type.ToContextualType());
+ return _uiDefinitionBuilder.Build(id, name, description, wrappedSchema);
+ }
+
private void EnsureSchemaExists(ConfigurationInfo info)
{
if (info.Path is null)
diff --git a/Shoko.Server/Services/Configuration/ShokoJsonSchemaGenerator.cs b/Shoko.Server/Services/Configuration/ShokoJsonSchemaGenerator.cs
index 178943dc29..1199193cc3 100644
--- a/Shoko.Server/Services/Configuration/ShokoJsonSchemaGenerator.cs
+++ b/Shoko.Server/Services/Configuration/ShokoJsonSchemaGenerator.cs
@@ -8,18 +8,22 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
-using Namotion.Reflection;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
using NJsonSchema;
using NJsonSchema.Generation;
using NJsonSchema.Generation.TypeMappers;
using NJsonSchema.NewtonsoftJson.Generation;
+using Namotion.Reflection;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Newtonsoft.Json.Serialization;
+using Shoko.Abstractions.Actions;
using Shoko.Abstractions.Config;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Components;
using Shoko.Abstractions.Config.Enums;
using Shoko.Abstractions.Extensions;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Components;
+using Shoko.Abstractions.UI.Enums;
using Shoko.Server.Plugin;
using JsonIgnoreAttribute = Newtonsoft.Json.JsonIgnoreAttribute;
@@ -42,19 +46,122 @@ public class ShokoJsonSchemaGenerator(JsonSerializerSettings newtonsoftJsonSeria
private Type? _currentType = null;
- private readonly Dictionary ClassUIDefinition, Dictionary> PropertyUIDefinitions)> _schemaCache = [];
+ ///
+ /// Which serialiser the type currently being walked is read and written
+ /// by. It used to be derived from the type itself, which only works for
+ /// configurations — an action is populated by
+ /// without
+ /// implementing .
+ ///
+ private bool _isNewtonsoftJson = false;
+
+ private Func
public required JsonSchema Schema { get; init; }
+ ///
+ /// The typed builders the generator filled in while walking the
+ /// configuration type, keyed by the schema node they describe. The UI
+ /// definition is joined from these and , rather than
+ /// from the x-uiDefinition bag the generator emits for the
+ /// validator.
+ ///
+ internal IReadOnlyDictionary UiBuilders { get; init; } = new Dictionary();
+
+ ///
+ /// The value converters the configuration's own serializer would use.
+ ///
+ internal UiEmitContext? EmitContext { get; init; }
+
///
/// Whether or not the configuration has custom actions.
///
diff --git a/Shoko.Server/Services/SystemService.cs b/Shoko.Server/Services/SystemService.cs
index 056fcf6da0..e4e15ce6f3 100644
--- a/Shoko.Server/Services/SystemService.cs
+++ b/Shoko.Server/Services/SystemService.cs
@@ -413,6 +413,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton();
diff --git a/Shoko.Server/Settings/AVDumpSettings.cs b/Shoko.Server/Settings/AVDumpSettings.cs
index bd125cd1a1..c6974c137a 100644
--- a/Shoko.Server/Settings/AVDumpSettings.cs
+++ b/Shoko.Server/Settings/AVDumpSettings.cs
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
-using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/AniDbSettings.cs b/Shoko.Server/Settings/AniDbSettings.cs
index c28b69e820..e4c9596451 100644
--- a/Shoko.Server/Settings/AniDbSettings.cs
+++ b/Shoko.Server/Settings/AniDbSettings.cs
@@ -4,7 +4,8 @@
using Microsoft.Extensions.Logging;
using Shoko.Abstractions.Config;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
using Shoko.Server.Providers.AniDB;
using Shoko.Server.Providers.AniDB.Interfaces;
using Shoko.Server.Server;
@@ -35,7 +36,7 @@ public class AniDbSettings
[CustomAction(
Theme = DisplayColorTheme.Primary,
- Position = DisplayButtonPosition.Top,
+ Position = DisplayButtonPosition.Start,
SectionName = "Login"
)]
public ConfigurationActionResult Test(ConfigurationActionContext context)
diff --git a/Shoko.Server/Settings/AnidbRateLimitSettings.cs b/Shoko.Server/Settings/AnidbRateLimitSettings.cs
index e4400f06dd..da5763efd5 100644
--- a/Shoko.Server/Settings/AnidbRateLimitSettings.cs
+++ b/Shoko.Server/Settings/AnidbRateLimitSettings.cs
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
-using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/ConnectivityMonitorDefinition.cs b/Shoko.Server/Settings/ConnectivityMonitorDefinition.cs
index 0e2253f241..2509adeddf 100644
--- a/Shoko.Server/Settings/ConnectivityMonitorDefinition.cs
+++ b/Shoko.Server/Settings/ConnectivityMonitorDefinition.cs
@@ -1,9 +1,9 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
-using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
using Shoko.Abstractions.Connectivity;
using Shoko.Abstractions.Connectivity.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/ConnectivitySettings.cs b/Shoko.Server/Settings/ConnectivitySettings.cs
index a41373be4a..f671be151e 100644
--- a/Shoko.Server/Settings/ConnectivitySettings.cs
+++ b/Shoko.Server/Settings/ConnectivitySettings.cs
@@ -1,6 +1,6 @@
using System.Collections.Generic;
-using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/DatabaseSettings.cs b/Shoko.Server/Settings/DatabaseSettings.cs
index 54ab1bf13d..b096612ac3 100644
--- a/Shoko.Server/Settings/DatabaseSettings.cs
+++ b/Shoko.Server/Settings/DatabaseSettings.cs
@@ -5,7 +5,8 @@
using System.Linq;
using Newtonsoft.Json;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
using Shoko.Server.Server;
using Shoko.Server.Services;
diff --git a/Shoko.Server/Settings/ImageSettings.cs b/Shoko.Server/Settings/ImageSettings.cs
index d9f9a64fd0..47e506b06b 100644
--- a/Shoko.Server/Settings/ImageSettings.cs
+++ b/Shoko.Server/Settings/ImageSettings.cs
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
-using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/ImageTemplateUrlConfiguration.cs b/Shoko.Server/Settings/ImageTemplateUrlConfiguration.cs
index cac78c4c42..a57866da6c 100644
--- a/Shoko.Server/Settings/ImageTemplateUrlConfiguration.cs
+++ b/Shoko.Server/Settings/ImageTemplateUrlConfiguration.cs
@@ -5,6 +5,7 @@
using Shoko.Abstractions.Config.Attributes;
using Shoko.Abstractions.Config.Enums;
using Shoko.Abstractions.Metadata.Enums;
+using Shoko.Abstractions.UI.Attributes;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/ImportSettings.cs b/Shoko.Server/Settings/ImportSettings.cs
index 5a3e3701ce..1087f2248c 100644
--- a/Shoko.Server/Settings/ImportSettings.cs
+++ b/Shoko.Server/Settings/ImportSettings.cs
@@ -4,10 +4,11 @@
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text.RegularExpressions;
-using Newtonsoft.Json;
using NLog;
+using Newtonsoft.Json;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/LanguageSettings.cs b/Shoko.Server/Settings/LanguageSettings.cs
index 6a907e05f7..30c51f0b47 100644
--- a/Shoko.Server/Settings/LanguageSettings.cs
+++ b/Shoko.Server/Settings/LanguageSettings.cs
@@ -4,6 +4,7 @@
using Newtonsoft.Json.Converters;
using Shoko.Abstractions.Config.Attributes;
using Shoko.Abstractions.Metadata.Enums;
+using Shoko.Abstractions.UI.Attributes;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/LogLevelRuleConfiguration.cs b/Shoko.Server/Settings/LogLevelRuleConfiguration.cs
index c6fc729a78..114a6cc4e4 100644
--- a/Shoko.Server/Settings/LogLevelRuleConfiguration.cs
+++ b/Shoko.Server/Settings/LogLevelRuleConfiguration.cs
@@ -8,6 +8,8 @@
using Shoko.Abstractions.Config.Enums;
using Shoko.Abstractions.Config.Services;
using Shoko.Abstractions.Plugin;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/LoggingSettings.cs b/Shoko.Server/Settings/LoggingSettings.cs
index 8d8f9b962f..3ef4b80335 100644
--- a/Shoko.Server/Settings/LoggingSettings.cs
+++ b/Shoko.Server/Settings/LoggingSettings.cs
@@ -4,8 +4,9 @@
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
using Shoko.Abstractions.Logging.Models;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/PluginSettings.cs b/Shoko.Server/Settings/PluginSettings.cs
index 0dcab580f8..aeaa570025 100644
--- a/Shoko.Server/Settings/PluginSettings.cs
+++ b/Shoko.Server/Settings/PluginSettings.cs
@@ -3,7 +3,8 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
using Shoko.Server.Server;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/QueueProcessorSettings.cs b/Shoko.Server/Settings/QueueProcessorSettings.cs
index f729520df1..d8013bf609 100644
--- a/Shoko.Server/Settings/QueueProcessorSettings.cs
+++ b/Shoko.Server/Settings/QueueProcessorSettings.cs
@@ -4,7 +4,8 @@
using System.IO;
using Newtonsoft.Json;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
using Shoko.QueueProcessor;
using Shoko.Server.Scheduling.Jobs.Shoko;
using Shoko.Server.Services;
diff --git a/Shoko.Server/Settings/ServerSettings.cs b/Shoko.Server/Settings/ServerSettings.cs
index da240e82e4..19503f16c5 100644
--- a/Shoko.Server/Settings/ServerSettings.cs
+++ b/Shoko.Server/Settings/ServerSettings.cs
@@ -6,9 +6,10 @@
using Newtonsoft.Json.Converters;
using Shoko.Abstractions.Config;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
using Shoko.Abstractions.Config.Services;
using Shoko.Abstractions.Plugin;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/TMDBSettings.cs b/Shoko.Server/Settings/TMDBSettings.cs
index 2dd9788fd2..e3dea7a44c 100644
--- a/Shoko.Server/Settings/TMDBSettings.cs
+++ b/Shoko.Server/Settings/TMDBSettings.cs
@@ -5,9 +5,10 @@
using JetBrains.Annotations;
using Newtonsoft.Json;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
using Shoko.Abstractions.Extensions;
using Shoko.Abstractions.Metadata.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/TmdbRateLimitSettings.cs b/Shoko.Server/Settings/TmdbRateLimitSettings.cs
index b069ed2472..b11685318e 100644
--- a/Shoko.Server/Settings/TmdbRateLimitSettings.cs
+++ b/Shoko.Server/Settings/TmdbRateLimitSettings.cs
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/VideoHashingServiceSettings.cs b/Shoko.Server/Settings/VideoHashingServiceSettings.cs
index 2e64215aec..cec5c8adde 100644
--- a/Shoko.Server/Settings/VideoHashingServiceSettings.cs
+++ b/Shoko.Server/Settings/VideoHashingServiceSettings.cs
@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using Shoko.Abstractions.Config;
-using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
using Shoko.Server.Services;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/VideoReleaseServiceSettings.cs b/Shoko.Server/Settings/VideoReleaseServiceSettings.cs
index 4bd54792af..5e00b927c9 100644
--- a/Shoko.Server/Settings/VideoReleaseServiceSettings.cs
+++ b/Shoko.Server/Settings/VideoReleaseServiceSettings.cs
@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using Shoko.Abstractions.Config;
-using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
using Shoko.Server.Services;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Settings/WebSettings.cs b/Shoko.Server/Settings/WebSettings.cs
index 35d05ddcda..0378fe7019 100644
--- a/Shoko.Server/Settings/WebSettings.cs
+++ b/Shoko.Server/Settings/WebSettings.cs
@@ -2,8 +2,9 @@
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
using Shoko.Abstractions.Config.Attributes;
-using Shoko.Abstractions.Config.Enums;
using Shoko.Abstractions.Plugin;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
namespace Shoko.Server.Settings;
diff --git a/Shoko.Server/Shoko.Server.csproj b/Shoko.Server/Shoko.Server.csproj
index 94a91e2f1e..aced928266 100644
--- a/Shoko.Server/Shoko.Server.csproj
+++ b/Shoko.Server/Shoko.Server.csproj
@@ -110,5 +110,14 @@
+
+
diff --git a/Shoko.Tests/BuildTools/Analyzers/ConfigurationTypeAnalyzerTests.cs b/Shoko.Tests/BuildTools/Analyzers/ConfigurationTypeAnalyzerTests.cs
new file mode 100644
index 0000000000..06470bb8ea
--- /dev/null
+++ b/Shoko.Tests/BuildTools/Analyzers/ConfigurationTypeAnalyzerTests.cs
@@ -0,0 +1,638 @@
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Testing;
+using Shoko.BuildTools.Analyzers;
+using Xunit;
+
+namespace Shoko.Tests.BuildTools.Analyzers;
+
+///
+/// Tests for .
+///
+///
+/// The Shoko configuration contract is stubbed into every test compilation instead of referencing
+/// Shoko.Abstractions, so the tests stay hermetic and pin the fully qualified names the
+/// analyzer matches on.
+///
+public class ConfigurationTypeAnalyzerTests
+{
+ private const string Contract = """
+ namespace Shoko.Abstractions.Config
+ {
+ public interface IConfiguration { }
+ public interface INewtonsoftJsonConfiguration : IConfiguration { }
+ }
+
+ namespace Shoko.Abstractions.Actions
+ {
+ public interface IExecutableAction { }
+ }
+
+ namespace Shoko.Abstractions.UI.Enums
+ {
+ public enum DisplayListType
+ {
+ Auto = 0,
+ EnumCheckbox = 1,
+ ComplexDropdown = 2,
+ ComplexTab = 3,
+ ComplexInline = 4,
+ }
+ }
+
+ namespace Shoko.Abstractions.UI.Attributes
+ {
+ using Shoko.Abstractions.UI.Enums;
+
+ [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Field)]
+ public class ListAttribute : System.Attribute
+ {
+ public DisplayListType ListType { get; set; }
+ }
+ }
+ """;
+
+ private static Task VerifyAsync(string source, params DiagnosticResult[] expected)
+ {
+ var test = new CSharpAnalyzerTest
+ {
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
+ TestState = { Sources = { Contract, source } },
+ };
+ test.ExpectedDiagnostics.AddRange(expected);
+ return test.RunAsync();
+ }
+
+ [Fact]
+ public async Task ListOfList_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+
+ public class MyConfig : IConfiguration
+ {
+ public {|#0:List>|} Nested { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.NestedCollection)
+ .WithLocation(0)
+ .WithArguments("Nested", "List>", "list"));
+ }
+
+ [Fact]
+ public async Task DictionaryOfDictionary_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+
+ public class MyConfig : IConfiguration
+ {
+ public {|#0:Dictionary>|} Nested { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.NestedCollection)
+ .WithLocation(0)
+ .WithArguments("Nested", "Dictionary>", "dictionary"));
+ }
+
+ [Fact]
+ public async Task ListOfDictionary_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+
+ public class MyConfig : IConfiguration
+ {
+ public {|#0:List>|} Nested { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.NestedCollection)
+ .WithLocation(0)
+ .WithArguments("Nested", "List>", "list"));
+ }
+
+ [Fact]
+ public async Task DictionaryOfCollections_IsNotReported()
+ {
+ // The two levels get distinct keys ("+Dict" and "+List"), so the
+ // generator produces a usable schema. A dictionary of scalar arrays
+ // is an ordinary shape and must not be rejected.
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+
+ public class MyConfig : IConfiguration
+ {
+ public Dictionary> Lists { get; set; } = new();
+ public Dictionary Arrays { get; set; } = new();
+ }
+ """);
+ }
+
+ [Fact]
+ public async Task JaggedArray_IsReported()
+ {
+ await VerifyAsync("""
+ using Shoko.Abstractions.Config;
+
+ public class MyConfig : IConfiguration
+ {
+ public {|#0:string[][]|} Nested { get; set; } = new string[0][];
+ }
+ """,
+ new DiagnosticResult(Diagnostics.NestedCollection)
+ .WithLocation(0)
+ .WithArguments("Nested", "string[][]", "list"));
+ }
+
+ ///
+ /// The case a syntax-only check would miss: the nesting is only visible once the alias is
+ /// resolved by the semantic model.
+ ///
+ [Fact]
+ public async Task AliasedInnerCollection_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+ using MyAlias = System.Collections.Generic.List;
+
+ public class MyConfig : IConfiguration
+ {
+ public {|#0:List|} Nested { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.NestedCollection)
+ .WithLocation(0)
+ .WithArguments("Nested", "List>", "list"));
+ }
+
+ ///
+ /// The other case a syntax-only check would miss: the nesting only appears once the base
+ /// class's type parameter is substituted.
+ ///
+ [Fact]
+ public async Task GenericBaseSubstitutedToACollection_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+
+ public class SectionBase
+ {
+ public {|#0:List|} Items { get; set; } = new();
+ }
+
+ public class MyConfig : SectionBase>, IConfiguration
+ {
+ }
+ """,
+ new DiagnosticResult(Diagnostics.NestedCollection)
+ .WithLocation(0)
+ .WithArguments("Items", "List>", "list"));
+ }
+
+ ///
+ /// The supported way to model two levels: a class in between, which gets its own schema.
+ ///
+ [Fact]
+ public async Task ListOfClassHoldingAList_IsNotReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+
+ public class Section
+ {
+ public List Values { get; set; } = new();
+ }
+
+ public class MyConfig : IConfiguration
+ {
+ public List Sections { get; set; } = new();
+ public Dictionary Named { get; set; } = new();
+ public List Flat { get; set; } = new();
+ public string[] Array { get; set; } = new string[0];
+ public List Blobs { get; set; } = new();
+ }
+ """);
+ }
+
+ [Fact]
+ public async Task NonConfigurationType_IsNotReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+
+ public class NotAConfig
+ {
+ public List> Nested { get; set; } = new();
+ }
+ """);
+ }
+
+ [Fact]
+ public async Task IgnoredProperty_IsNotReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+
+ public class MyConfig : IConfiguration
+ {
+ [System.Text.Json.Serialization.JsonIgnore]
+ public List> Nested { get; set; } = new();
+ }
+ """);
+ }
+
+ [Fact]
+ public async Task SectionReachedFromAConfiguration_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+
+ public class Section
+ {
+ public {|#0:List>|} Nested { get; set; } = new();
+ }
+
+ public class MyConfig : IConfiguration
+ {
+ public Section Section { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.NestedCollection)
+ .WithLocation(0)
+ .WithArguments("Nested", "List>", "list"));
+ }
+
+ [Fact]
+ public async Task UnusableDictionaryKey_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+
+ public class MyKey
+ {
+ public string Value { get; set; } = "";
+ }
+
+ public class MyConfig : IConfiguration
+ {
+ public {|#0:Dictionary|} Keyed { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.UnusableDictionaryKey)
+ .WithLocation(0)
+ .WithArguments("Keyed", "MyKey"));
+ }
+
+ [Fact]
+ public async Task UsableDictionaryKeys_AreNotReported()
+ {
+ await VerifyAsync("""
+ using System;
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+
+ public enum Colour { Red, Green }
+
+ [Serializable]
+ public class MarkedKey
+ {
+ public string Value { get; set; } = "";
+ }
+
+ public class MyConfig : IConfiguration
+ {
+ public Dictionary ByString { get; set; } = new();
+ public Dictionary ByEnum { get; set; } = new();
+ public Dictionary ByGuid { get; set; } = new();
+ public Dictionary ByInt { get; set; } = new();
+ public Dictionary ByMarked { get; set; } = new();
+ }
+ """);
+ }
+
+ [Theory]
+ [InlineData("ComplexDropdown", "Dropdown")]
+ [InlineData("ComplexTab", "Tab")]
+ [InlineData("ComplexInline", "Inline")]
+ public async Task ComplexListTypeOnScalarElements_IsReported(string listType, string noun)
+ {
+ await VerifyAsync($$"""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+ using Shoko.Abstractions.UI.Attributes;
+ using Shoko.Abstractions.UI.Enums;
+
+ public class MyConfig : IConfiguration
+ {
+ [{|#0:List(ListType = DisplayListType.{{listType}})|}]
+ public List Names { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.IncompatibleListType)
+ .WithLocation(0)
+ .WithArguments("Names", noun, "class", listType, "string"));
+ }
+
+ ///
+ /// A class the generator refuses to register as a section container, because everything under
+ /// the System namespace is excluded.
+ ///
+ [Fact]
+ public async Task ComplexListTypeOnAFrameworkClass_IsReported()
+ {
+ await VerifyAsync("""
+ using System;
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+ using Shoko.Abstractions.UI.Attributes;
+ using Shoko.Abstractions.UI.Enums;
+
+ public class MyConfig : IConfiguration
+ {
+ [{|#0:List(ListType = DisplayListType.ComplexTab)|}]
+ public List Links { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.IncompatibleListType)
+ .WithLocation(0)
+ .WithArguments("Links", "Tab", "class", "ComplexTab", "Uri"));
+ }
+
+ [Fact]
+ public async Task EnumCheckboxOnNonEnumElements_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+ using Shoko.Abstractions.UI.Attributes;
+ using Shoko.Abstractions.UI.Enums;
+
+ public class MyConfig : IConfiguration
+ {
+ [{|#0:List(ListType = DisplayListType.EnumCheckbox)|}]
+ public List Names { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.IncompatibleListType)
+ .WithLocation(0)
+ .WithArguments("Names", "Checkbox", "enum", "EnumCheckbox", "string"));
+ }
+
+ [Theory]
+ [InlineData("ComplexDropdown", "Dropdown")]
+ [InlineData("ComplexTab", "Tab")]
+ [InlineData("ComplexInline", "Inline")]
+ public async Task ComplexListTypeWithoutAPrimaryKey_IsReported(string listType, string noun)
+ {
+ await VerifyAsync($$"""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Config;
+ using Shoko.Abstractions.UI.Attributes;
+ using Shoko.Abstractions.UI.Enums;
+
+ public class Section
+ {
+ public string Name { get; set; } = "";
+ }
+
+ public class MyConfig : IConfiguration
+ {
+ [{|#0:List(ListType = DisplayListType.{{listType}})|}]
+ public List Sections { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.MissingPrimaryKey)
+ .WithLocation(0)
+ .WithArguments("Sections", noun, "Section"));
+ }
+
+ ///
+ /// The schema flattens inheritance and the generator resolves an inherited key through
+ /// the flattened property set, so a base-declared [Key] satisfies the requirement.
+ ///
+ [Fact]
+ public async Task ComplexListTypeWithAnInheritedPrimaryKey_IsNotReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using System.ComponentModel.DataAnnotations;
+ using Shoko.Abstractions.Config;
+ using Shoko.Abstractions.UI.Attributes;
+ using Shoko.Abstractions.UI.Enums;
+
+ public class SectionBase
+ {
+ [Key]
+ public string Id { get; set; } = "";
+ }
+
+ public class Section : SectionBase
+ {
+ public string Name { get; set; } = "";
+ }
+
+ public class MyConfig : IConfiguration
+ {
+ [List(ListType = DisplayListType.ComplexDropdown)]
+ public List Sections { get; set; } = new();
+ }
+ """);
+ }
+ [Fact]
+ public async Task ComplexListTypeWithAnIgnoredPrimaryKey_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using System.ComponentModel.DataAnnotations;
+ using Shoko.Abstractions.Config;
+ using Shoko.Abstractions.UI.Attributes;
+ using Shoko.Abstractions.UI.Enums;
+
+ public class Section
+ {
+ [Key]
+ [System.Text.Json.Serialization.JsonIgnore]
+ public string Id { get; set; } = "";
+
+ public string Name { get; set; } = "";
+ }
+
+ public class MyConfig : IConfiguration
+ {
+ [{|#0:List(ListType = DisplayListType.ComplexInline)|}]
+ public List Sections { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.MissingPrimaryKey)
+ .WithLocation(0)
+ .WithArguments("Sections", "Inline", "Section"));
+ }
+
+ [Fact]
+ public async Task MatchingListTypes_AreNotReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using System.ComponentModel.DataAnnotations;
+ using Shoko.Abstractions.Config;
+ using Shoko.Abstractions.UI.Attributes;
+ using Shoko.Abstractions.UI.Enums;
+
+ public enum Colour { Red, Green }
+
+ public class Keyed
+ {
+ [Key]
+ public string Id { get; set; } = "";
+
+ public string Name { get; set; } = "";
+ }
+
+ public class Section
+ {
+ public string Name { get; set; } = "";
+ }
+
+ public class MyConfig : IConfiguration
+ {
+ [List(ListType = DisplayListType.EnumCheckbox)]
+ public List Colours { get; set; } = new();
+
+ // The item type declares the key.
+ [List(ListType = DisplayListType.ComplexDropdown)]
+ public List Keyed { get; set; } = new();
+
+ // The property itself declares the key.
+ [Key]
+ [List(ListType = DisplayListType.ComplexTab)]
+ public List Sections { get; set; } = new();
+
+ [List(ListType = DisplayListType.Auto)]
+ public List Auto { get; set; } = new();
+
+ [List(ListType = DisplayListType.Auto)]
+ public List Names { get; set; } = new();
+
+ public List Unattributed { get; set; } = new();
+ }
+ """);
+ }
+
+ [Fact]
+ public async Task NonGenericDictionary_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections;
+ using Shoko.Abstractions.Config;
+
+ public class MyConfig : IConfiguration
+ {
+ public {|#0:Hashtable|} Table { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.NotAGenericDictionary)
+ .WithLocation(0)
+ .WithArguments("Table", "Hashtable"));
+ }
+
+ [Fact]
+ public async Task GenericDictionaryImplementations_AreNotReported()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using System.Collections.Concurrent;
+ using Shoko.Abstractions.Config;
+
+ public class MyConfig : IConfiguration
+ {
+ public SortedList Sorted { get; set; } = new();
+ public SortedDictionary SortedDict { get; set; } = new();
+ public ConcurrentDictionary Concurrent { get; set; } = new();
+ public IReadOnlyDictionary ReadOnly { get; set; } = new Dictionary();
+ public IDictionary Interface { get; set; } = new Dictionary();
+ }
+ """);
+ }
+
+ [Fact]
+ public async Task ActionParameter_IsReported()
+ {
+ // An action's invocation parameters are its own settable, serialized
+ // properties, walked by the same generator, so the same shape breaks it
+ // identically.
+ await VerifyAsync("""
+ using System.Collections.Generic;
+ using Shoko.Abstractions.Actions;
+
+ public class MyAction : IExecutableAction
+ {
+ public {|#0:List>|} Nested { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.NestedCollection)
+ .WithLocation(0)
+ .WithArguments("Nested", "List>", "list"));
+ }
+
+ [Fact]
+ public async Task TypeReachedFromAnAction_IsReported()
+ {
+ await VerifyAsync("""
+ using System.Collections;
+ using Shoko.Abstractions.Actions;
+
+ public class Parameters
+ {
+ public {|#0:Hashtable|} Table { get; set; } = new();
+ }
+
+ public class MyAction : IExecutableAction
+ {
+ public Parameters Parameters { get; set; } = new();
+ }
+ """,
+ new DiagnosticResult(Diagnostics.NotAGenericDictionary)
+ .WithLocation(0)
+ .WithArguments("Table", "Hashtable"));
+ }
+
+ [Fact]
+ public async Task ActionMetadataSurface_IsNotReported()
+ {
+ // Every metadata member is a scalar, so none of the rules can fire on
+ // one. The index deliberately does not filter them out.
+ await VerifyAsync("""
+ using Shoko.Abstractions.Actions;
+
+ public class MyAction : IExecutableAction
+ {
+ public string Name => "Do The Thing";
+ public string? Description => null;
+ public bool RequiresConfirmation => true;
+ }
+ """);
+ }
+
+ [Fact]
+ public async Task ATypeImplementingNeitherContract_IsNotAnalysed()
+ {
+ await VerifyAsync("""
+ using System.Collections.Generic;
+
+ public class NotAnything
+ {
+ public List> Nested { get; set; } = new();
+ }
+ """);
+ }
+}
diff --git a/Shoko.Tests/Data/Configuration/Inheriting.schema.golden.json b/Shoko.Tests/Data/Configuration/Inheriting.schema.golden.json
new file mode 100644
index 0000000000..743441fe07
--- /dev/null
+++ b/Shoko.Tests/Data/Configuration/Inheriting.schema.golden.json
@@ -0,0 +1,108 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "title": "Inheriting",
+ "type": "object",
+ "properties": {
+ "Name": {
+ "title": "Inherited Name",
+ "type": "string",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "INHERITED_NAME",
+ "envVarOverridable": false
+ }
+ },
+ "Mode": {
+ "title": "Mode",
+ "$ref": "#/definitions/TwinMode",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "slow-and-steady",
+ "aliasValues": ""
+ },
+ {
+ "value": "balanced",
+ "aliasValues": ""
+ },
+ {
+ "value": "fast",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "Endpoints": {
+ "title": "Endpoints",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TwinEndpoint"
+ }
+ },
+ "Count": {
+ "title": "Derived Count",
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "definitions": {
+ "TwinMode": {
+ "type": "string",
+ "description": "",
+ "x-enumNames": [
+ "Slow",
+ "Balanced",
+ "Fast"
+ ],
+ "x-enum-descriptions": [
+ "Takes its time.",
+ null,
+ null
+ ],
+ "enum": [
+ "slow-and-steady",
+ "balanced",
+ "fast"
+ ]
+ },
+ "TwinEndpoint": {
+ "type": "object",
+ "properties": {
+ "ID": {
+ "title": "ID",
+ "type": "string"
+ },
+ "Url": {
+ "title": "Url",
+ "type": "string",
+ "format": "uri"
+ },
+ "Mode": {
+ "title": "Mode",
+ "$ref": "#/definitions/TwinMode",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "slow-and-steady",
+ "aliasValues": ""
+ },
+ {
+ "value": "balanced",
+ "aliasValues": ""
+ },
+ {
+ "value": "fast",
+ "aliasValues": ""
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shoko.Tests/Data/Configuration/NewtonsoftTwin.schema.golden.json b/Shoko.Tests/Data/Configuration/NewtonsoftTwin.schema.golden.json
new file mode 100644
index 0000000000..47a552f8bc
--- /dev/null
+++ b/Shoko.Tests/Data/Configuration/NewtonsoftTwin.schema.golden.json
@@ -0,0 +1,262 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "title": "Newtonsoft Twin",
+ "type": "object",
+ "properties": {
+ "Body": {
+ "title": "Body",
+ "$ref": "#/definitions/TwinBody"
+ }
+ },
+ "definitions": {
+ "TwinBody": {
+ "title": "Twin Body",
+ "type": "object",
+ "properties": {
+ "Name": {
+ "title": "Display Name",
+ "type": "string",
+ "default": "shoko"
+ },
+ "Enabled": {
+ "title": "Enabled",
+ "type": "boolean",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "TWIN_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "Count": {
+ "title": "Count",
+ "type": "integer",
+ "format": "int32",
+ "maximum": 100.0,
+ "minimum": 1.0
+ },
+ "Ratio": {
+ "title": "Ratio",
+ "type": "number",
+ "format": "double"
+ },
+ "Mode": {
+ "title": "Mode",
+ "$ref": "#/definitions/TwinMode",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "slow-and-steady",
+ "aliasValues": ""
+ },
+ {
+ "value": "balanced",
+ "aliasValues": ""
+ },
+ {
+ "value": "fast",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "Modes": {
+ "title": "Modes",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TwinMode"
+ }
+ },
+ "Secret": {
+ "title": "Secret",
+ "type": "string"
+ },
+ "Note": {
+ "title": "Note",
+ "type": "string"
+ },
+ "Script": {
+ "title": "Script",
+ "type": "string"
+ },
+ "Endpoints": {
+ "title": "Endpoints",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TwinEndpoint"
+ }
+ },
+ "Weights": {
+ "title": "Weights",
+ "type": "object",
+ "x-dictionaryKey": {
+ "$ref": "#/definitions/TwinMode"
+ },
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "Toggles": {
+ "title": "Toggles",
+ "type": "object",
+ "additionalProperties": {
+ "type": "boolean"
+ }
+ },
+ "Picked": {
+ "title": "Picked",
+ "$ref": "#/definitions/SelectComponent_1"
+ }
+ }
+ },
+ "TwinMode": {
+ "type": "string",
+ "description": "",
+ "x-enumNames": [
+ "Slow",
+ "Balanced",
+ "Fast"
+ ],
+ "x-enum-descriptions": [
+ "Takes its time.",
+ null,
+ null
+ ],
+ "enum": [
+ "slow-and-steady",
+ "balanced",
+ "fast"
+ ]
+ },
+ "TwinEndpoint": {
+ "type": "object",
+ "properties": {
+ "ID": {
+ "title": "ID",
+ "type": "string"
+ },
+ "Url": {
+ "title": "Url",
+ "type": "string",
+ "format": "uri"
+ },
+ "Mode": {
+ "title": "Mode",
+ "$ref": "#/definitions/TwinMode",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "slow-and-steady",
+ "aliasValues": ""
+ },
+ {
+ "value": "balanced",
+ "aliasValues": ""
+ },
+ {
+ "value": "fast",
+ "aliasValues": ""
+ }
+ ]
+ }
+ }
+ }
+ },
+ "SelectComponent_1": {
+ "type": "object",
+ "description": "A select component for the UI.\n ",
+ "required": [
+ "options",
+ "groups"
+ ],
+ "properties": {
+ "options": {
+ "type": "array",
+ "description": "The options for the select component in the UI.\n ",
+ "default": [],
+ "items": {
+ "$ref": "#/definitions/SelectOption_1"
+ }
+ },
+ "groups": {
+ "type": "array",
+ "description": "The groups for the select component in the UI.\n ",
+ "default": [],
+ "items": {
+ "$ref": "#/definitions/SelectGroup"
+ }
+ }
+ }
+ },
+ "SelectOption_1": {
+ "type": "object",
+ "description": "A select option for the UI.\n ",
+ "required": [
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "The label for the option.\n "
+ },
+ "groupId": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "description": "The unique identifier for the group this option belongs to, or\n`null` if it should be rendered outside of a group.\n "
+ },
+ "value": {
+ "type": "string",
+ "description": "The value of the option.\n "
+ },
+ "selected": {
+ "type": "boolean",
+ "description": "Whether the option is selected.\n ",
+ "default": false
+ },
+ "default": {
+ "type": "boolean",
+ "description": "Whether the option is the default.\n ",
+ "default": false
+ },
+ "disabled": {
+ "type": "boolean",
+ "description": "Whether the option is disabled.\n ",
+ "default": false
+ }
+ }
+ },
+ "SelectGroup": {
+ "type": "object",
+ "description": "A select group for the UI.\n ",
+ "required": [
+ "label",
+ "disabled"
+ ],
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "The unique identifier for the group.\n "
+ },
+ "label": {
+ "type": "string",
+ "description": "The label for the group.\n "
+ },
+ "disabled": {
+ "type": "boolean",
+ "description": "Whether the group is disabled.\n ",
+ "default": false
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shoko.Tests/Data/Configuration/ServerSettings.schema.golden.json b/Shoko.Tests/Data/Configuration/ServerSettings.schema.golden.json
new file mode 100644
index 0000000000..27bed3714c
--- /dev/null
+++ b/Shoko.Tests/Data/Configuration/ServerSettings.schema.golden.json
@@ -0,0 +1,2704 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "title": "Core Settings",
+ "type": "object",
+ "description": "Core Settings for the server.",
+ "properties": {
+ "SettingsVersion": {
+ "title": "Settings Version",
+ "type": "integer",
+ "description": "Settings version. Will be incremented by the system. DO NOT TOUCH.",
+ "format": "int32"
+ },
+ "ImagesPath": {
+ "title": "Images Path",
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "Path where the images are stored. If set to `null` then it will use\nthe default location.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_IMAGES_PATH",
+ "envVarOverridable": true
+ }
+ },
+ "Culture": {
+ "title": "Culture",
+ "type": "string",
+ "description": "The culture to use when formatting strings."
+ },
+ "FirstRun": {
+ "title": "First Run",
+ "type": "boolean",
+ "description": "Indicates this the first time the server has been started.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "SHOKO_FIRST_RUN",
+ "envVarOverridable": true
+ }
+ },
+ "AutoGroupSeries": {
+ "title": "Auto Group Series",
+ "type": "boolean",
+ "description": "Auto group series based on the detected relation."
+ },
+ "AutoGroupSeriesRelationExclusions": {
+ "title": "Auto Group Series Relation Exclusions",
+ "type": "array",
+ "description": "The list of relation types to exclude from auto grouping.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "AutoGroupSeriesUseScoreAlgorithm": {
+ "title": "Auto Group Series Use Score Algorithm",
+ "type": "boolean",
+ "description": "Use the score algorithm for auto grouping."
+ },
+ "Image": {
+ "title": "Image",
+ "description": "Configure the image settings for Shoko.",
+ "$ref": "#/definitions/Image"
+ },
+ "CachingDatabaseTimeout": {
+ "title": "Caching Database Timeout (seconds)",
+ "type": "integer",
+ "description": "The maximum number of seconds to cache a repository during startup.",
+ "format": "int32",
+ "maximum": 600.0,
+ "minimum": 1.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "DB_CACHING_TIMEOUT",
+ "envVarOverridable": false
+ }
+ },
+ "ThreadPoolMinThreads": {
+ "title": "Thread Pool Minimum Threads",
+ "type": "integer",
+ "description": "Minimum number of .NET thread pool worker/I-O completion threads to\nkeep warm. Raising this avoids the runtime's gradual \"hill-climbing\"\nthread injection delay under bursts of concurrent, blocking, or\nsynchronous work. Set to 0 to leave the runtime's own default\nuntouched. Positive values are used directly. Negative values are\ntreated as a multiplier against the CPU count, offset by one, e.g.\n-1 means CPU count x 2, -2 means CPU count x 3, up to -9 meaning\nCPU count x 10.",
+ "format": "int32",
+ "maximum": 2147483647.0,
+ "minimum": -9.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "THREADPOOL_MIN_THREADS",
+ "envVarOverridable": false
+ }
+ },
+ "Import": {
+ "title": "Import",
+ "description": "The import settings.",
+ "$ref": "#/definitions/Import"
+ },
+ "AniDb": {
+ "title": "AniDB",
+ "description": "Configure the information Shoko retrieves from AniDB for the series in\nyour collection, and set your preferences for MyList options and the\ngeneral updating of AniDB data.",
+ "$ref": "#/definitions/AniDb"
+ },
+ "TMDB": {
+ "title": "TMDB",
+ "description": "Configure the information Shoko retrieves from TMDB for the series in\nyour collection.",
+ "$ref": "#/definitions/TMDB"
+ },
+ "Database": {
+ "title": "Database",
+ "description": "Configure the main database settings. These settings will not affect the\nqueue database.",
+ "$ref": "#/definitions/Database"
+ },
+ "Queue": {
+ "title": "Queue",
+ "description": "The queue processor settings.",
+ "$ref": "#/definitions/QueueProcessor"
+ },
+ "Connectivity": {
+ "title": "Connectivity",
+ "description": "The connectivity settings.",
+ "$ref": "#/definitions/Connectivity"
+ },
+ "Language": {
+ "title": "Language",
+ "description": "The language settings.",
+ "$ref": "#/definitions/Language"
+ },
+ "Plex": {
+ "title": "Plex",
+ "description": "The Plex settings.",
+ "$ref": "#/definitions/Plex"
+ },
+ "Plugins": {
+ "title": "Plugins",
+ "description": "The plugin settings.",
+ "$ref": "#/definitions/Plugin"
+ },
+ "ReleaseComparisonPreferences": {
+ "title": "Release Comparison Preferences",
+ "description": "Release-level comparison preferences used by the release management system.",
+ "$ref": "#/definitions/ReleaseComparisonPreferences"
+ },
+ "Logging": {
+ "title": "Logging",
+ "description": "The logging settings.",
+ "$ref": "#/definitions/Logging"
+ },
+ "Linux": {
+ "title": "Linux",
+ "description": "Linux runtime settings. Windows users can ignore this.",
+ "$ref": "#/definitions/Linux"
+ },
+ "Web": {
+ "title": "Web",
+ "description": "Configure settings related to the HTTP(S) hosting.",
+ "$ref": "#/definitions/Web"
+ },
+ "WebUI_Settings": {
+ "title": "Settings",
+ "type": "string",
+ "description": "The web UI settings, as a stringified JSON object.",
+ "x-uiDefinition": {
+ "elementType": "code-block",
+ "requiresRestart": false,
+ "envVar": "SHOKO_WEBUI_SETTINGS",
+ "envVarOverridable": true
+ }
+ },
+ "DumpSettingsOnStart": {
+ "title": "Dump Settings On Start",
+ "type": "boolean",
+ "description": "Dump the settings to the log file on startup.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "SHOKO_DUMP_SETTINGS_ON_START",
+ "envVarOverridable": false
+ }
+ },
+ "SentryOptOut": {
+ "title": "Sentry Opt-Out",
+ "type": "boolean",
+ "description": "Disable Sentry error reporting in the server. This will not affect the\nweb UI error reporting.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SENTRY_OPT_OUT",
+ "envVarOverridable": false
+ }
+ }
+ },
+ "definitions": {
+ "Image": {
+ "type": "object",
+ "properties": {
+ "ImageTemplateUrls": {
+ "title": "Image Template Urls",
+ "type": "array",
+ "description": "List of user-registered image template URLs.\n ",
+ "items": {
+ "$ref": "#/definitions/ImageTemplateUrl"
+ }
+ },
+ "AutoPurge": {
+ "title": "Auto Purge Orphaned Images",
+ "type": "boolean",
+ "description": "Automatically purge orphaned images on a periodic schedule (every 24 hours).\nOrphaned images are no longer referenced by any entity in the database.\n "
+ },
+ "AutoValidate": {
+ "title": "Auto Validate Image Integrity",
+ "type": "boolean",
+ "description": "Automatically validate the integrity of all available images on a periodic schedule\n(every 24 hours). Invalid images will be re-downloaded.\n "
+ }
+ }
+ },
+ "ImageTemplateUrl": {
+ "type": "object",
+ "description": "Configuration for the image template URL.\n ",
+ "required": [
+ "ImageSource"
+ ],
+ "properties": {
+ "ImageSource": {
+ "title": "Image Source",
+ "description": "The image source.\n ",
+ "default": "AniDB",
+ "$ref": "#/definitions/DataSource",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "AniDB",
+ "aliasValues": ""
+ },
+ {
+ "value": "TMDB",
+ "aliasValues": ""
+ },
+ {
+ "value": "TvDB",
+ "aliasValues": ""
+ },
+ {
+ "value": "AniList",
+ "aliasValues": ""
+ },
+ {
+ "value": "Animeshon",
+ "aliasValues": ""
+ },
+ {
+ "value": "Kitsu",
+ "aliasValues": ""
+ },
+ {
+ "value": "MAL",
+ "aliasValues": ""
+ },
+ {
+ "value": "FanartTV",
+ "aliasValues": ""
+ },
+ {
+ "value": "IMDB",
+ "aliasValues": ""
+ },
+ {
+ "value": "OMDB",
+ "aliasValues": ""
+ },
+ {
+ "value": "TraktTv",
+ "aliasValues": ""
+ },
+ {
+ "value": "TPDB",
+ "aliasValues": ""
+ },
+ {
+ "value": "MediUX",
+ "aliasValues": ""
+ },
+ {
+ "value": "SimKL",
+ "aliasValues": ""
+ },
+ {
+ "value": "Plugin",
+ "aliasValues": ""
+ },
+ {
+ "value": "LocallyGenerated",
+ "aliasValues": ""
+ },
+ {
+ "value": "None",
+ "aliasValues": ""
+ },
+ {
+ "value": "User",
+ "aliasValues": ""
+ },
+ {
+ "value": "Shoko",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "TemplateUrl": {
+ "title": "Image Template URL",
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "DataSource": {
+ "type": "string",
+ "description": "The data source.\n ",
+ "x-enumNames": [
+ "AniDB",
+ "TMDB",
+ "TvDB",
+ "AniList",
+ "Animeshon",
+ "Kitsu",
+ "MAL",
+ "FanartTV",
+ "IMDB",
+ "OMDB",
+ "TraktTv",
+ "TPDB",
+ "MediUX",
+ "SimKL",
+ "Plugin",
+ "LocallyGenerated",
+ "None",
+ "User",
+ "Shoko"
+ ],
+ "enum": [
+ "AniDB",
+ "TMDB",
+ "TvDB",
+ "AniList",
+ "Animeshon",
+ "Kitsu",
+ "MAL",
+ "FanartTV",
+ "IMDB",
+ "OMDB",
+ "TraktTv",
+ "TPDB",
+ "MediUX",
+ "SimKL",
+ "Plugin",
+ "LocallyGenerated",
+ "None",
+ "User",
+ "Shoko"
+ ]
+ },
+ "Import": {
+ "type": "object",
+ "properties": {
+ "VideoExtensions": {
+ "title": "Video Extensions",
+ "type": "array",
+ "description": "List of video file extensions to import.",
+ "default": [
+ "MKV",
+ "AVI",
+ "MP4",
+ "MOV",
+ "OGM",
+ "WMV",
+ "MPG",
+ "MPEG",
+ "MK3D",
+ "M4V"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "Exclude": {
+ "title": "Exclude Regex Patterns",
+ "type": "array",
+ "description": "List of regular expression patterns to exclude any files that match on the full path.",
+ "default": [
+ "[\\\\\\/]\\$RECYCLE\\.BIN[\\\\\\/]",
+ "[\\\\\\/]\\.Recycle\\.Bin[\\\\\\/]",
+ "[\\\\\\/]\\.Trash-\\d+[\\\\\\/]"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "RunOnStart": {
+ "title": "Run Import on Startup",
+ "type": "boolean",
+ "description": "Run the import scheduled task on startup."
+ },
+ "ScanDropFoldersOnStart": {
+ "title": "Scan Source Managed Folders on Startup",
+ "type": "boolean",
+ "description": "Scan all source managed folders on server startup."
+ },
+ "CleanUpStructure": {
+ "title": "Clean Up Structure on Managed Folder Scan",
+ "type": "boolean",
+ "description": "Determines if we should clean up the managed folder structure when doing\na scan of the folder by default if it's not overridden on a per job\nbasis."
+ },
+ "CheckFileSize": {
+ "title": "Check File Size on Managed Folder Scan",
+ "type": "boolean",
+ "description": "Check the file size when scanning managed folders. This has a higher\ncost than not checking the file size, as it will check the file size on\nEVERY file in the folder at the path to scan."
+ },
+ "MaxAutoScanAttemptsPerFile": {
+ "title": "Max auto-scan attempts per file",
+ "type": "integer",
+ "description": "Max auto-scan attempts per file for unrecognized files.",
+ "format": "int32",
+ "default": 15,
+ "maximum": 100.0,
+ "minimum": 0.0
+ },
+ "UseExistingFileWatchedStatus": {
+ "title": "Use Existing Watched Status",
+ "type": "boolean",
+ "description": "Use the existing episode watched status when importing files."
+ },
+ "AutomaticallyDeleteDuplicatesOnImport": {
+ "title": "Automatically Delete Duplicates on Import",
+ "type": "boolean",
+ "description": "Automatically delete duplicate files on import after hashing them."
+ },
+ "FileLockChecking": {
+ "title": "Use File Lock Checking",
+ "type": "boolean",
+ "description": "Check if a file is currently being written to when reacting to events in the file watcher.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true
+ }
+ },
+ "FileLockWaitTimeMS": {
+ "title": "File Lock Wait Time (ms)",
+ "type": "integer",
+ "description": "Time between each check to see if a file is currently being written to.",
+ "format": "int32",
+ "default": 4000,
+ "maximum": 60000.0,
+ "minimum": 1000.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true
+ }
+ },
+ "AggressiveFileLockChecking": {
+ "title": "Use Aggressive File Lock Checking",
+ "type": "boolean",
+ "description": "For file systems without proper locking, with this option enabled, Shoko\nwill try to use a more aggressive method to check if a file is currently\nbeing written to.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true
+ }
+ },
+ "AggressiveFileLockWaitTimeSeconds": {
+ "title": "Aggressive File Lock Wait Time (seconds)",
+ "type": "integer",
+ "description": "Time between each check to see if a file is currently being written to.",
+ "format": "int32",
+ "default": 8,
+ "maximum": 60.0,
+ "minimum": 0.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true
+ }
+ },
+ "SkipDiskSpaceChecks": {
+ "title": "Skip Disk Space Checks",
+ "type": "boolean",
+ "description": "Skip disk space checks during the move/rename of files.",
+ "default": false
+ },
+ "MediaInfoPath": {
+ "title": "Override MediaInfo Path",
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "Optional. Custom path to MediaInfo executable."
+ },
+ "MediaInfoTimeoutMinutes": {
+ "title": "MediaInfo Timeout (minutes)",
+ "type": "integer",
+ "description": "Timeout for wait for MediaInfo to finish scanning a file before killing\nit.",
+ "format": "int32",
+ "default": 5,
+ "maximum": 60.0,
+ "minimum": 1.0
+ }
+ }
+ },
+ "AniDb": {
+ "type": "object",
+ "required": [
+ "HTTPServerUrl"
+ ],
+ "properties": {
+ "Username": {
+ "title": "Username",
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "AniDB username.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "ANIDB_USER",
+ "envVarOverridable": false
+ }
+ },
+ "Password": {
+ "title": "Password",
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "AniDB password.",
+ "x-uiDefinition": {
+ "elementType": "password",
+ "requiresRestart": false,
+ "envVar": "ANIDB_PASS",
+ "envVarOverridable": false
+ }
+ },
+ "DownloadCharacters": {
+ "title": "Download Character Images",
+ "type": "boolean",
+ "description": "Download character images from AniDB."
+ },
+ "DownloadCreators": {
+ "title": "Download Creator Images & Data",
+ "type": "boolean",
+ "description": "Download creator images and extra data from AniDB. The extra data is\nneeded for staff to get images and to properly detect studios among\nother staff, etc.."
+ },
+ "DownloadRelatedAnime": {
+ "title": "Always Download Related Anime",
+ "type": "boolean",
+ "description": "Always download related anime, regardless of if the auto-group setting\nis enabled."
+ },
+ "MaxRelationDepth": {
+ "title": "Max Relation Depth",
+ "type": "integer",
+ "description": "Max relation depth when scheduling anime to be updated/fetched.",
+ "format": "int32",
+ "maximum": 5.0,
+ "minimum": 0.0
+ },
+ "MinimumHoursToRedownloadAnimeInfo": {
+ "title": "Minimum Hours To Redownload Anime Info",
+ "type": "integer",
+ "description": "The minimum number of hours to wait before attempting to re-downloading\nan AniDB anime.",
+ "format": "int32",
+ "default": 24,
+ "maximum": 48.0,
+ "minimum": 0.0
+ },
+ "AutomaticallyImportSeries": {
+ "title": "Automatically Import Series",
+ "type": "boolean",
+ "description": "Automatically create a Shoko Series for each AniDB anime."
+ },
+ "MyList_AddFiles": {
+ "title": "Add Files",
+ "type": "boolean"
+ },
+ "MyList_ReadWatched": {
+ "title": "Read Watched",
+ "type": "boolean"
+ },
+ "MyList_ReadUnwatched": {
+ "title": "Read Unwatched",
+ "type": "boolean"
+ },
+ "MyList_SetWatched": {
+ "title": "Set Watched",
+ "type": "boolean"
+ },
+ "MyList_SetUnwatched": {
+ "title": "Set Unwatched",
+ "type": "boolean"
+ },
+ "MyList_StorageState": {
+ "title": "Storage State",
+ "$ref": "#/definitions/MyList_State",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "Unknown",
+ "aliasValues": ""
+ },
+ {
+ "value": "HDD",
+ "aliasValues": ""
+ },
+ {
+ "value": "Disk",
+ "aliasValues": ""
+ },
+ {
+ "value": "Deleted",
+ "aliasValues": ""
+ },
+ {
+ "value": "Remote",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "MyList_DeleteType": {
+ "title": "Delete Type",
+ "$ref": "#/definitions/AniDBFileDeleteType",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "Delete",
+ "aliasValues": ""
+ },
+ {
+ "value": "DeleteLocalOnly",
+ "aliasValues": ""
+ },
+ {
+ "value": "MarkDeleted",
+ "aliasValues": ""
+ },
+ {
+ "value": "MarkExternalStorage",
+ "aliasValues": ""
+ },
+ {
+ "value": "MarkUnknown",
+ "aliasValues": ""
+ },
+ {
+ "value": "MarkDisk",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "MyList_RetainedBackupCount": {
+ "title": "Retained Backup Count",
+ "type": "integer",
+ "description": "Number of days to retain backups of the downloaded MyList for the user.",
+ "format": "int32",
+ "maximum": 99.0,
+ "minimum": 0.0
+ },
+ "Calendar_UpdateFrequency": {
+ "title": "Calendar",
+ "description": "Check which AniDB anime is currently airing in the next/previous week,\nand schedule an update for all of them, adding them to the local\ncollection if they're not already part of it.",
+ "$ref": "#/definitions/ScheduledUpdateFrequency",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "Never",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursSix",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursTwelve",
+ "aliasValues": ""
+ },
+ {
+ "value": "Daily",
+ "aliasValues": ""
+ },
+ {
+ "value": "WeekOne",
+ "aliasValues": ""
+ },
+ {
+ "value": "MonthOne",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "Anime_UpdateFrequency": {
+ "title": "Anime Updates",
+ "description": "Check which AniDB anime has been updated since the last time we asked,\nand schedule an update for any in the local collection.",
+ "$ref": "#/definitions/ScheduledUpdateFrequency",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "Never",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursSix",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursTwelve",
+ "aliasValues": ""
+ },
+ {
+ "value": "Daily",
+ "aliasValues": ""
+ },
+ {
+ "value": "WeekOne",
+ "aliasValues": ""
+ },
+ {
+ "value": "MonthOne",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "File_UpdateFrequency": {
+ "title": "Files with missing info",
+ "description": "Check for any files with missing info and schedule them for a re-check.",
+ "$ref": "#/definitions/ScheduledUpdateFrequency",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "Never",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursSix",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursTwelve",
+ "aliasValues": ""
+ },
+ {
+ "value": "Daily",
+ "aliasValues": ""
+ },
+ {
+ "value": "WeekOne",
+ "aliasValues": ""
+ },
+ {
+ "value": "MonthOne",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "MyList_UpdateFrequency": {
+ "title": "MyList",
+ "description": "Sync the MyList with the local collection.",
+ "$ref": "#/definitions/ScheduledUpdateFrequency",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "Never",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursSix",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursTwelve",
+ "aliasValues": ""
+ },
+ {
+ "value": "Daily",
+ "aliasValues": ""
+ },
+ {
+ "value": "WeekOne",
+ "aliasValues": ""
+ },
+ {
+ "value": "MonthOne",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "Notification_UpdateFrequency": {
+ "title": "Notifications & Messages",
+ "description": "Check for any unread notifications and messages and download them if\nthere are any.",
+ "$ref": "#/definitions/ScheduledUpdateFrequency",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "Never",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursSix",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursTwelve",
+ "aliasValues": ""
+ },
+ {
+ "value": "Daily",
+ "aliasValues": ""
+ },
+ {
+ "value": "WeekOne",
+ "aliasValues": ""
+ },
+ {
+ "value": "MonthOne",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "Notification_HandleMovedFiles": {
+ "title": "Handle Moved Files",
+ "type": "boolean",
+ "description": "Handle 'File has been moved' messages from AniDB."
+ },
+ "ImageCdnUrl": {
+ "title": "Image CDN URL",
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "The base URL or URL template for the image CDN to use.",
+ "format": "uri",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "ANIDB_IMAGE_CDN_URL",
+ "envVarOverridable": false
+ }
+ },
+ "TitleCacheUrl": {
+ "title": "Title Cache URL",
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "The full URL for where to fetch the title cache used for the \"remote\"\nAniDB search capabilities within Shoko.",
+ "format": "uri",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "ANIDB_TITLE_CACHE_URL",
+ "envVarOverridable": false
+ }
+ },
+ "HTTPServerUrl": {
+ "title": "HTTP API Server URL",
+ "type": "string",
+ "description": "HTTP API server to communicate with.",
+ "format": "uri",
+ "minLength": 1,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "ANIDB_HTTP_API_URL",
+ "envVarOverridable": false
+ }
+ },
+ "HTTPRateLimit": {
+ "title": "Rate Limiting",
+ "description": "Settings for rate limiting the HTTP API.",
+ "$ref": "#/definitions/AnidbRateLimit"
+ },
+ "UDPServerAddress": {
+ "title": "Server Address",
+ "type": "string",
+ "description": "UDP server to communicate with.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "ANIDB_UDP_API_ADDRESS",
+ "envVarOverridable": false
+ }
+ },
+ "UDPServerPort": {
+ "title": "Server Port",
+ "type": "integer",
+ "description": "UDP server port to communicate with.",
+ "maximum": 65535.0,
+ "minimum": 1.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "ANIDB_UDP_API_PORT",
+ "envVarOverridable": false
+ }
+ },
+ "ClientPort": {
+ "title": "Client Port",
+ "type": "integer",
+ "description": "UDP client port to communicate with.",
+ "maximum": 65535.0,
+ "minimum": 1.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true
+ }
+ },
+ "UDPPingFrequency": {
+ "title": "Ping Frequency (seconds)",
+ "type": "integer",
+ "description": "How often to ping the UDP server to keep the session alive.",
+ "format": "int32",
+ "maximum": 120.0,
+ "minimum": 30.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true
+ }
+ },
+ "UDPRateLimit": {
+ "title": "Rate Limiting",
+ "description": "Settings for rate limiting the UDP API.",
+ "$ref": "#/definitions/AnidbRateLimit"
+ },
+ "AVDumpKey": {
+ "title": "API Key",
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "The API key to use when using AVDump to dump files for AniDB. The key is\ncreated on their site and is separate from the username and password.",
+ "x-uiDefinition": {
+ "elementType": "password",
+ "requiresRestart": false,
+ "envVar": "ANIDB_AVDUMP_API_KEY",
+ "envVarOverridable": false
+ }
+ },
+ "AVDumpClientPort": {
+ "title": "Client Port",
+ "type": "integer",
+ "description": "The client port to prefer binding to when using AVDump to dump files for\nAniDB."
+ },
+ "AVDump": {
+ "title": "Advanced AVDump Settings",
+ "description": "AVDump settings",
+ "$ref": "#/definitions/AVDump"
+ }
+ }
+ },
+ "MyList_State": {
+ "type": "string",
+ "description": "",
+ "x-enumNames": [
+ "Unknown",
+ "HDD",
+ "Disk",
+ "Deleted",
+ "Remote"
+ ],
+ "enum": [
+ "Unknown",
+ "HDD",
+ "Disk",
+ "Deleted",
+ "Remote"
+ ]
+ },
+ "AniDBFileDeleteType": {
+ "type": "string",
+ "description": "",
+ "x-enumNames": [
+ "Delete",
+ "DeleteLocalOnly",
+ "MarkDeleted",
+ "MarkExternalStorage",
+ "MarkUnknown",
+ "MarkDisk"
+ ],
+ "enum": [
+ "Delete",
+ "DeleteLocalOnly",
+ "MarkDeleted",
+ "MarkExternalStorage",
+ "MarkUnknown",
+ "MarkDisk"
+ ]
+ },
+ "ScheduledUpdateFrequency": {
+ "type": "string",
+ "description": "",
+ "x-enumNames": [
+ "Never",
+ "HoursSix",
+ "HoursTwelve",
+ "Daily",
+ "WeekOne",
+ "MonthOne"
+ ],
+ "enum": [
+ "Never",
+ "HoursSix",
+ "HoursTwelve",
+ "Daily",
+ "WeekOne",
+ "MonthOne"
+ ]
+ },
+ "AnidbRateLimit": {
+ "type": "object",
+ "description": "Settings for rate limiting the Anidb provider.",
+ "properties": {
+ "BaseRateInSeconds": {
+ "title": "Base Rate (seconds)",
+ "type": "integer",
+ "description": "Base rate in seconds for request and the multipliers.",
+ "format": "int32",
+ "maximum": 60.0,
+ "minimum": 2.0
+ },
+ "SlowRateMultiplier": {
+ "title": "Slow Rate Multiplier",
+ "type": "integer",
+ "description": "Slow rate multiplier applied to the Base Rate.",
+ "format": "int32",
+ "maximum": 99.0,
+ "minimum": 2.0
+ },
+ "SlowRatePeriodMultiplier": {
+ "title": "Slow Rate Period Multiplier",
+ "type": "integer",
+ "description": "Slow rate period multiplier applied to the Base Rate.",
+ "format": "int32",
+ "maximum": 99.0,
+ "minimum": 2.0
+ },
+ "ResetPeriodMultiplier": {
+ "title": "Reset Period Multiplier",
+ "type": "integer",
+ "description": "Reset period multiplier applied to the Base Rate.",
+ "format": "int32",
+ "maximum": 99.0,
+ "minimum": 2.0
+ }
+ }
+ },
+ "AVDump": {
+ "type": "object",
+ "properties": {
+ "MaxConcurrency": {
+ "title": "Max Concurrency",
+ "type": "integer",
+ "description": "Max concurrent hashing jobs to run in AVDump at the same time.",
+ "format": "int32",
+ "maximum": 64.0,
+ "minimum": 0.0
+ },
+ "CreqTimeout": {
+ "title": "Creq Timeout",
+ "type": "integer",
+ "description": "Sets the timeout for a creq upload before retrying or bailing.",
+ "format": "int32",
+ "maximum": 300.0,
+ "minimum": 20.0
+ },
+ "CreqMaxRetries": {
+ "title": "Creq Max Retries",
+ "type": "integer",
+ "description": "Sets the max retry attempts for a creq upload before bailing.",
+ "format": "int32",
+ "maximum": 20.0,
+ "minimum": 1.0
+ }
+ }
+ },
+ "TMDB": {
+ "type": "object",
+ "properties": {
+ "AutoLink": {
+ "title": "Auto Link",
+ "type": "boolean",
+ "description": "Automagically link AniDB anime to TMDB shows and movies."
+ },
+ "AutoLinkRestricted": {
+ "title": "Auto Link Restricted",
+ "type": "boolean",
+ "description": "Automagically link restricted AniDB anime to TMDB shows and movies.\nAutoLink also needs to be set for this setting to take\neffect."
+ },
+ "ConsiderExistingOtherLinks": {
+ "title": "Consider Existing Other Links",
+ "type": "boolean",
+ "description": "Determines whether to consider existing cross-reference links to other\nAniDB anime when linking an AniDB anime to a TMDB show."
+ },
+ "DownloadAllTitles": {
+ "title": "Download All Titles",
+ "type": "boolean",
+ "description": "Indicates that all titles should be stored locally for the TMDB entity,\notherwise it will use\n or\n depending\non the entity type to determine which titles to store locally."
+ },
+ "DownloadAllOverviews": {
+ "title": "Download All Overviews",
+ "type": "boolean",
+ "description": "Indicates that all overviews should be stored locally for the TMDB\nentity, otherwise it will use\n to determine\nwhich overviews should be stored locally."
+ },
+ "DownloadAllContentRatings": {
+ "title": "Download All Content Ratings",
+ "type": "boolean",
+ "description": "Indicates that all content-ratings should be stored locally for the TMDB\nentity, otherwise it will use\n or\n depending\non the entity type to determine which content-ratings to store locally."
+ },
+ "ImageLanguageOrder": {
+ "title": "Image Language Order",
+ "type": "array",
+ "description": "Image language preference order, in text form for storage.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "AutoDownloadCrewAndCast": {
+ "title": "Auto Download Crew And Cast",
+ "type": "boolean",
+ "description": "Automagically download crew and cast for movies and tv shows in the\nlocal collection."
+ },
+ "AutoDownloadCollections": {
+ "title": "Auto Download Collections",
+ "type": "boolean",
+ "description": "Automagically download collections for movies and tv shows in the local\ncollection."
+ },
+ "AutoDownloadAlternateOrdering": {
+ "title": "Auto Download Alternate Ordering",
+ "type": "boolean",
+ "description": "Automagically download episode groups to use with alternate ordering\nfor tv shows."
+ },
+ "AutoDownloadNetworks": {
+ "title": "Auto Download Networks",
+ "type": "boolean",
+ "description": "Automagically download networks for tv shows in the local collection."
+ },
+ "AutoDownloadBackdrops": {
+ "title": "Auto Download Backdrops",
+ "type": "boolean",
+ "description": "Automagically download backdrops for TMDB entities that supports\nbackdrops up to images per entity."
+ },
+ "MaxAutoBackdrops": {
+ "title": "Max Auto Backdrops",
+ "type": "integer",
+ "description": "The maximum number of backdrops to download for each TMDB entity that\nsupports backdrops.",
+ "format": "int32",
+ "maximum": 30.0,
+ "minimum": 0.0
+ },
+ "AutoDownloadPosters": {
+ "title": "Auto Download Posters",
+ "type": "boolean",
+ "description": "Automagically download posters for TMDB entities that supports\nposters up to images per entity."
+ },
+ "MaxAutoPosters": {
+ "title": "Max Auto Posters",
+ "type": "integer",
+ "description": "The maximum number of posters to download for each TMDB entity that\nsupports posters.",
+ "format": "int32",
+ "maximum": 30.0,
+ "minimum": 0.0
+ },
+ "AutoDownloadLogos": {
+ "title": "Auto Download Logos",
+ "type": "boolean",
+ "description": "Automagically download logos for TMDB entities that supports\nlogos up to images per entity."
+ },
+ "MaxAutoLogos": {
+ "title": "Max Auto Logos",
+ "type": "integer",
+ "description": "The maximum number of logos to download for each TMDB entity that\nsupports logos.",
+ "format": "int32",
+ "maximum": 30.0,
+ "minimum": 0.0
+ },
+ "AutoDownloadThumbnails": {
+ "title": "Auto Download Thumbnails",
+ "type": "boolean",
+ "description": "Automagically download thumbnail images for TMDB entities that supports\nthumbnails."
+ },
+ "MaxAutoThumbnails": {
+ "title": "Max Auto Thumbnails",
+ "type": "integer",
+ "description": "The maximum number of thumbnail images to download for each TMDB entity\nthat supports thumbnail images.",
+ "format": "int32",
+ "maximum": 30.0,
+ "minimum": 0.0
+ },
+ "AutoDownloadStaffImages": {
+ "title": "Auto Download Staff Images",
+ "type": "boolean",
+ "description": "Automagically download staff member and voice-actor images."
+ },
+ "MaxAutoStaffImages": {
+ "title": "Max Auto Staff Images",
+ "type": "integer",
+ "description": "The maximum number of staff member and voice-actor images to download\nfor each TMDB entity that supports staff member and voice-actor images.",
+ "format": "int32",
+ "maximum": 30.0,
+ "minimum": 0.0
+ },
+ "AutoDownloadStudioImages": {
+ "title": "Auto Download Studio Images",
+ "type": "boolean",
+ "description": "Automagically download studio and company images."
+ },
+ "UserApiKey": {
+ "title": "User Api Key",
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "Optional. User provided TMDB API key to use.",
+ "x-uiDefinition": {
+ "elementType": "password",
+ "requiresRestart": true,
+ "envVar": "TMDB_API_KEY",
+ "envVarOverridable": false
+ }
+ },
+ "ImageCdnUrl": {
+ "title": "Image CDN URL",
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "The base URL or URL template for the image CDN to use.",
+ "format": "uri",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "TMDB_IMAGE_CDN_URL",
+ "envVarOverridable": false
+ }
+ },
+ "IncrementalChangesWindowDays": {
+ "title": "Incremental Changes Window Days",
+ "type": "integer",
+ "description": "The number of days to check for incremental changes. Set to `0` to\ndisable incremental changes.",
+ "format": "int32",
+ "default": 1,
+ "maximum": 14.0,
+ "minimum": 0.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "TMDB_CHANGES_WINDOW_DAYS",
+ "envVarOverridable": false
+ }
+ },
+ "AutoSearchShowCandidateCount": {
+ "title": "Auto Search Show Candidate Count",
+ "type": "integer",
+ "description": "The maximum number of TMDB search candidates to evaluate per auto-search\nattempt for shows. Each candidate that passes the animation filter is fetched\nin full (title translations + episode count) and scored; the highest-scoring\nresult is used. Higher values improve accuracy at the cost of more TMDB\nAPI calls. All calls are paced by `TmdbRateLimiter` (sliding-window,\n~40 req/sec) with automatic 429 backoff, so increasing this value slows\nsearches but will not trigger rate-limit errors.\nThe year-free candidate pool cap is `2 × this value`. At the minimum\nof 1, year-free searches collect at most 2 candidates total — sufficient for\nmost titles, but edge cases may benefit from a higher value.",
+ "format": "int32",
+ "maximum": 10.0,
+ "minimum": 1.0
+ },
+ "AutoSearchMovieCandidateCount": {
+ "title": "Auto Search Movie Candidate Count",
+ "type": "integer",
+ "description": "The maximum number of TMDB search candidates to evaluate per auto-search\nattempt for movies. Each candidate that passes the animation filter is fetched\nin full (title translations + release dates) and scored; the highest-scoring\nresult is used. Higher values improve accuracy at the cost of more TMDB\nAPI calls. All calls are paced by `TmdbRateLimiter` (sliding-window,\n~40 req/sec) with automatic 429 backoff, so increasing this value slows\nsearches but will not trigger rate-limit errors.\nThe year-free candidate pool cap is `2 × this value`. At the minimum\nof 1, year-free searches collect at most 2 candidates total — sufficient for\nmost titles, but edge cases may benefit from a higher value.",
+ "format": "int32",
+ "maximum": 10.0,
+ "minimum": 1.0
+ },
+ "RateLimit": {
+ "title": "Rate Limit",
+ "description": "Rate limit settings for the TMDB API.",
+ "$ref": "#/definitions/TmdbRateLimit"
+ },
+ "AutoPurgeUnlinkedAfterDays": {
+ "title": "Auto Purge Unlinked After Days",
+ "type": "integer",
+ "description": "Number of days a TMDB show or movie can remain in the local database\nwithout any AniDB cross-reference before it is automatically purged.\nSet to `0` to disable automatic purging.",
+ "format": "int32",
+ "default": 14,
+ "maximum": 365.0,
+ "minimum": 0.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "TMDB_AUTO_PURGE_UNLINKED_AFTER_DAYS",
+ "envVarOverridable": false
+ }
+ }
+ }
+ },
+ "TmdbRateLimit": {
+ "type": "object",
+ "description": "Settings for rate limiting the TMDB provider.",
+ "properties": {
+ "MaxRequestsPerWindow": {
+ "title": "Max Requests Per Window",
+ "type": "integer",
+ "description": "Maximum number of requests allowed within the rate limit window.\nTMDB enforces a maximum of 40 requests per second; this defaults to 10 to avoid overwhelming\nend-user hardware with API requests and the data processing that follows each one.\n ",
+ "format": "int32",
+ "maximum": 40.0,
+ "minimum": 1.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "TMDB_RATE_LIMIT_MAX_REQUESTS_PER_WINDOW",
+ "envVarOverridable": false
+ }
+ },
+ "WindowDurationMs": {
+ "title": "Window Duration (ms)",
+ "type": "integer",
+ "description": "Duration of the sliding rate limit window in milliseconds.",
+ "format": "int32",
+ "maximum": 10000.0,
+ "minimum": 100.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "TMDB_RATE_LIMIT_WINDOW_DURATION_MS",
+ "envVarOverridable": false
+ }
+ }
+ }
+ },
+ "Database": {
+ "type": "object",
+ "required": [
+ "Type"
+ ],
+ "properties": {
+ "Type": {
+ "title": "Database Type",
+ "description": "Determines the database backend to use for the main Shoko database.",
+ "$ref": "#/definitions/DatabaseType",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": true,
+ "envVar": "DB_TYPE",
+ "envVarOverridable": false,
+ "enumDefinitions": [
+ {
+ "value": "SQLite",
+ "aliasValues": ""
+ },
+ {
+ "value": "MSSQL",
+ "aliasValues": "SQLServer, "
+ },
+ {
+ "value": "MySQL",
+ "aliasValues": "MariaDB"
+ }
+ ]
+ }
+ },
+ "SQLite_DatabaseFile": {
+ "title": "Filename",
+ "type": "string",
+ "description": "File name of the SQLite database file.",
+ "pattern": ".+\\.(?:db3?|sqlite3?)$",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "DB_SQLITE_FILENAME",
+ "envVarOverridable": false
+ }
+ },
+ "MySqliteDirectory": {
+ "title": "Directory",
+ "type": "string",
+ "description": "Directory where the SQLite database file is stored.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "DB_SQLITE_DIRECTORY",
+ "envVarOverridable": false
+ }
+ },
+ "Host": {
+ "title": "Host",
+ "type": "string",
+ "description": "SQL Server or MySQL/MariaDB host address, optionally with port if it's\nnot the default port for the database type.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "DB_HOST",
+ "envVarOverridable": false
+ }
+ },
+ "Username": {
+ "title": "Username",
+ "type": "string",
+ "description": "SQL Server or MySQL/MariaDB username.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "DB_USER",
+ "envVarOverridable": false
+ }
+ },
+ "Password": {
+ "title": "Password",
+ "type": "string",
+ "description": "SQL Server or MySQL/MariaDB password.",
+ "x-uiDefinition": {
+ "elementType": "password",
+ "requiresRestart": true,
+ "envVar": "DB_PASS",
+ "envVarOverridable": false
+ }
+ },
+ "Schema": {
+ "title": "Database Name",
+ "type": "string",
+ "description": "SQL Server or MySQL/MariaDB database name.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "DB_NAME",
+ "envVarOverridable": false
+ }
+ },
+ "OverrideConnectionString": {
+ "title": "Connection String",
+ "type": "string",
+ "description": "Advanced SQL Server or MySQL/MariaDB connection string.",
+ "x-uiDefinition": {
+ "elementType": "text-area",
+ "requiresRestart": true,
+ "envVar": "DB_CONNECTION_STRING",
+ "envVarOverridable": false
+ }
+ },
+ "DatabaseBackupDirectory": {
+ "title": "Backup Directory",
+ "type": "string",
+ "description": "Directory for where to store the backups during database migrations.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "DB_BACKUP_DIRECTORY",
+ "envVarOverridable": false
+ }
+ },
+ "LogSqlInConsole": {
+ "title": "Log SQL to Console",
+ "type": "boolean",
+ "description": "Log SQL statements to standard output. They will not appear in the log file or Web UI live log.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "DB_LOG_TO_CONSOLE",
+ "envVarOverridable": false
+ }
+ }
+ }
+ },
+ "DatabaseType": {
+ "type": "string",
+ "description": "",
+ "x-enumNames": [
+ "SQLite",
+ "SQLServer",
+ "SQLServer",
+ "MySQL",
+ "MariaDB"
+ ],
+ "enum": [
+ "SQLite",
+ "MSSQL",
+ "SQLServer",
+ "MySQL",
+ "MariaDB"
+ ]
+ },
+ "QueueProcessor": {
+ "type": "object",
+ "properties": {
+ "Provider": {
+ "title": "Database Type",
+ "description": "Determines the database backend to use for the queue.",
+ "$ref": "#/definitions/DatabaseProvider",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": true,
+ "envVar": "QUEUE_DB_TYPE",
+ "envVarOverridable": false,
+ "enumDefinitions": [
+ {
+ "value": "SQLite",
+ "aliasValues": ""
+ },
+ {
+ "value": "MySQL",
+ "aliasValues": ""
+ },
+ {
+ "value": "SqlServer",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "SQLiteFilePath": {
+ "title": "Database File",
+ "type": "string",
+ "description": "Path to the SQLite queue database file. Relative paths are resolved against\nthe application data directory. Only used when Provider is SQLite.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "QUEUE_SQLITE_FILE",
+ "envVarOverridable": false
+ }
+ },
+ "ConnectionString": {
+ "title": "Connection String",
+ "type": "string",
+ "description": "The connection string for the queue database. For SQLite, this can be used to append additional options to the connection string. For all other providers this is a required field.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "QUEUE_CONNECTION_STRING",
+ "envVarOverridable": false
+ }
+ },
+ "MaxTotalWorkers": {
+ "title": "Max Total Workers",
+ "type": "integer",
+ "description": "Maximum total concurrent workers across all pools. Defaults to CPU count + 4.",
+ "format": "int32",
+ "maximum": 2147483647.0,
+ "minimum": -1.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "QUEUE_MAX_WORKERS",
+ "envVarOverridable": false
+ }
+ },
+ "FlushIntervalMs": {
+ "title": "Flush Interval (ms)",
+ "type": "integer",
+ "description": "Milliseconds between coalesced DB flush operations.",
+ "format": "int32",
+ "maximum": 30000.0,
+ "minimum": 100.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "QUEUE_FLUSH_INTERVAL",
+ "envVarOverridable": false
+ }
+ },
+ "MaxFlushBatch": {
+ "title": "Max Flush Batch",
+ "type": "integer",
+ "description": "Maximum number of jobs to flush in a single DB batch.",
+ "format": "int32",
+ "maximum": 10000.0,
+ "minimum": 1.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "QUEUE_MAX_FLUSH_BATCH",
+ "envVarOverridable": false
+ }
+ },
+ "LimitedConcurrencyOverrides": {
+ "title": "Limited Concurrency Overrides",
+ "type": "object",
+ "description": "A map of job type name to the number of allowed concurrent workers of that type.",
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-uiDefinition": {
+ "elementType": "record",
+ "requiresRestart": true,
+ "envVar": "QUEUE_CONCURRENCY_LIMITS",
+ "envVarOverridable": false
+ }
+ }
+ }
+ },
+ "DatabaseProvider": {
+ "type": "string",
+ "description": "Supported database providers for the queue.",
+ "x-enumNames": [
+ "SQLite",
+ "MySQL",
+ "SqlServer"
+ ],
+ "enum": [
+ "SQLite",
+ "MySQL",
+ "SqlServer"
+ ]
+ },
+ "Connectivity": {
+ "type": "object",
+ "properties": {
+ "MonitorDefinitions": {
+ "title": "Monitor Definitions",
+ "type": [
+ "array",
+ "null"
+ ],
+ "description": "The list of connectivity monitor definitions used for WAN availability checks.\nWhen `null`, the built-in defaults are used.",
+ "items": {
+ "$ref": "#/definitions/ConnectivityMonitorDefinition"
+ }
+ }
+ }
+ },
+ "ConnectivityMonitorDefinition": {
+ "type": "object",
+ "description": "A concrete connectivity monitor definition used for WAN availability checks.",
+ "required": [
+ "Name",
+ "Address"
+ ],
+ "properties": {
+ "Key": {
+ "title": "Key",
+ "type": "string",
+ "default": "New Monitor"
+ },
+ "Name": {
+ "title": "Monitor Name",
+ "type": "string",
+ "description": "A unique, human-readable name for this monitor.",
+ "default": "",
+ "minLength": 1
+ },
+ "Type": {
+ "title": "Check Type",
+ "description": "The type of HTTP request to perform against the Address.",
+ "default": "HEAD",
+ "$ref": "#/definitions/ConnectivityCheckType",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "GET",
+ "aliasValues": ""
+ },
+ {
+ "value": "HEAD",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "Address": {
+ "title": "URL",
+ "type": "string",
+ "description": "The URL to check for connectivity.",
+ "format": "uri",
+ "default": "",
+ "minLength": 1
+ }
+ }
+ },
+ "ConnectivityCheckType": {
+ "type": "string",
+ "description": "The type of HTTP request to use for a connectivity check.",
+ "x-enumNames": [
+ "Get",
+ "Head"
+ ],
+ "enum": [
+ "GET",
+ "HEAD"
+ ]
+ },
+ "Language": {
+ "type": "object",
+ "properties": {
+ "UseSynonyms": {
+ "title": "Use Synonyms",
+ "type": "boolean",
+ "description": "Use synonyms when selecting the preferred language from AniDB."
+ },
+ "SeriesTitleLanguageOrder": {
+ "title": "Series Title Language Order",
+ "type": "array",
+ "description": "Series / group title language preference order.",
+ "items": {
+ "type": "string"
+ },
+ "x-uiDefinition": {
+ "elementType": "list",
+ "requiresRestart": true
+ }
+ },
+ "SeriesTitleSourceOrder": {
+ "title": "Series Title Source Order",
+ "type": "array",
+ "description": "Series / group title source preference order.",
+ "items": {
+ "$ref": "#/definitions/DataSource"
+ },
+ "x-uiDefinition": {
+ "elementType": "list",
+ "requiresRestart": true
+ }
+ },
+ "EpisodeTitleLanguageOrder": {
+ "title": "Episode Title Language Order",
+ "type": "array",
+ "description": "Episode / season title language preference order.",
+ "items": {
+ "type": "string"
+ },
+ "x-uiDefinition": {
+ "elementType": "list",
+ "requiresRestart": true
+ }
+ },
+ "EpisodeTitleSourceOrder": {
+ "title": "Episode Title Source Order",
+ "type": "array",
+ "description": "Episode / season title source preference order.",
+ "items": {
+ "$ref": "#/definitions/DataSource"
+ },
+ "x-uiDefinition": {
+ "elementType": "list",
+ "requiresRestart": true
+ }
+ },
+ "DescriptionLanguageOrder": {
+ "title": "Description Language Order",
+ "type": "array",
+ "description": "Description language preference order.",
+ "items": {
+ "type": "string"
+ },
+ "x-uiDefinition": {
+ "elementType": "list",
+ "requiresRestart": true
+ }
+ },
+ "DescriptionSourceOrder": {
+ "title": "Description Source Order",
+ "type": "array",
+ "description": "Description source preference order.",
+ "items": {
+ "$ref": "#/definitions/DataSource"
+ },
+ "x-uiDefinition": {
+ "elementType": "list",
+ "requiresRestart": true
+ }
+ }
+ }
+ },
+ "Plex": {
+ "type": "object",
+ "properties": {
+ "Libraries": {
+ "title": "Libraries",
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "Server": {
+ "title": "Server",
+ "type": "string"
+ }
+ }
+ },
+ "Plugin": {
+ "type": "object",
+ "properties": {
+ "EnabledPlugins": {
+ "title": "Enabled Plugins",
+ "type": "object",
+ "description": "A list of all known plugins, with their enabled state.",
+ "additionalProperties": {
+ "type": "boolean"
+ },
+ "x-uiDefinition": {
+ "elementType": "record",
+ "requiresRestart": true,
+ "envVar": "SHOKO_ENABLED_PLUGINS",
+ "envVarOverridable": true
+ }
+ },
+ "Priority": {
+ "title": "Plugin Load Order",
+ "type": "array",
+ "description": "Load order of plugins. It will show both enabled and disabled, but\ndisabled plugins will not be loaded.",
+ "items": {
+ "type": "string"
+ },
+ "x-uiDefinition": {
+ "elementType": "list",
+ "requiresRestart": true,
+ "envVar": "SHOKO_PLUGIN_LOAD_ORDER",
+ "envVarOverridable": true
+ }
+ },
+ "Renamer": {
+ "title": "Renamer",
+ "description": "Settings for renamers.\n ",
+ "$ref": "#/definitions/Relocation"
+ },
+ "Updates": {
+ "title": "Updates",
+ "description": "Settings for plugin updates.\n ",
+ "$ref": "#/definitions/PluginUpdates"
+ }
+ }
+ },
+ "Relocation": {
+ "type": "object",
+ "properties": {
+ "RenameOnImport": {
+ "title": "Rename On Import",
+ "type": "boolean",
+ "description": "Indicates that we should rename a video file on import, and after metadata\nupdates when the metadata related to the file may have changed."
+ },
+ "MoveOnImport": {
+ "title": "Move On Import",
+ "type": "boolean",
+ "description": "Indicates that we should move a video file on import, and after metadata\nupdates when the metadata related to the file may have changed."
+ },
+ "AllowRelocationInsideDestinationOnImport": {
+ "title": "Allow Relocation Inside Destination On Import",
+ "type": "boolean",
+ "description": "Indicates that we should relocate a video file that lives inside a\ndrop destination managed folder that's not also a drop source on import."
+ }
+ }
+ },
+ "PluginUpdates": {
+ "type": "object",
+ "properties": {
+ "IsAutoSyncEnabled": {
+ "title": "Is Auto Sync Enabled",
+ "type": "boolean",
+ "description": "Whether automatic repository syncing is enabled.\n ",
+ "default": false
+ },
+ "IsAutoUpgradeEnabled": {
+ "title": "Is Auto Upgrade Enabled",
+ "type": "boolean",
+ "description": "Whether automatic plugin upgrades are enabled.\n ",
+ "default": false
+ },
+ "AutoUpdateFrequency": {
+ "title": "Auto Update Frequency",
+ "description": "How often to automatically check for and apply plugin updates if enabled.\nDefaults to every 6 hours.\n ",
+ "default": "HoursSix",
+ "$ref": "#/definitions/ScheduledUpdateFrequency",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "Never",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursSix",
+ "aliasValues": ""
+ },
+ {
+ "value": "HoursTwelve",
+ "aliasValues": ""
+ },
+ {
+ "value": "Daily",
+ "aliasValues": ""
+ },
+ {
+ "value": "WeekOne",
+ "aliasValues": ""
+ },
+ {
+ "value": "MonthOne",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "DefaultRepositoryStaleTime": {
+ "title": "Default Repository Stale Time",
+ "type": "string",
+ "description": "Default time before a repository's packages are considered stale.\nDefaults to 12 hours.\n ",
+ "format": "duration",
+ "default": "12:00:00.000000"
+ },
+ "InactivePluginVersionRetention": {
+ "title": "Inactive Plugin Version Retention",
+ "type": "string",
+ "description": "Time to retain old plugin versions before auto-cleanup. Defaults to\n30 days.\n ",
+ "format": "duration",
+ "default": "720.00:00.000000"
+ }
+ }
+ },
+ "ReleaseComparisonPreferences": {
+ "type": "object",
+ "properties": {
+ "SignalPriority": {
+ "title": "Signal Priority",
+ "type": "array",
+ "description": "Ordered list of quality signals used to rank release candidates.\nComparison stops at the first signal where the two candidates differ.",
+ "items": {
+ "$ref": "#/definitions/ReleaseSignalType"
+ }
+ },
+ "SourceOrder": {
+ "title": "Source Order",
+ "type": "array",
+ "description": "Ordered source preference: first entry is most preferred.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "ResolutionOrder": {
+ "title": "Resolution Order",
+ "type": "array",
+ "description": "Ordered resolution preference: first entry is most preferred.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "VideoCodecOrder": {
+ "title": "Video Codec Order",
+ "type": "array",
+ "description": "Ordered video codec preference: first entry is most preferred.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "AudioCodecOrder": {
+ "title": "Audio Codec Order",
+ "type": "array",
+ "description": "Ordered audio codec preference: first entry is most preferred.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "AudioLanguageOrder": {
+ "title": "Audio Language Order",
+ "type": "array",
+ "description": "Ordered audio language preference: first entry is most preferred.\nCandidates are compared against this list in order — the first preferred\nlanguage present in one candidate but not the other decides the winner.\nEmpty list means no language preference — audio language comparison is always a tie.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "SubtitleLanguageOrder": {
+ "title": "Subtitle Language Order",
+ "type": "array",
+ "description": "Ordered subtitle language preference: first entry is most preferred.\nCandidates are compared against this list in order — the first preferred\nlanguage present in one candidate but not the other decides the winner.\nEmpty list means no language preference — subtitle language comparison is always a tie.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "SubGroupOrder": {
+ "title": "Sub Group Order",
+ "type": "array",
+ "description": "Ordered release-group preference: first entry is most preferred.\nEmpty list means no group preference — subgroup comparison is always a tie.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "PreferHigherBitDepth": {
+ "title": "Prefer Higher Bit Depth",
+ "type": "boolean",
+ "description": "When true, 10-bit video is preferred over 8-bit; when false, the opposite."
+ },
+ "AutoDeleteOnImport": {
+ "title": "Auto Delete On Import",
+ "type": "boolean",
+ "description": "When true, the auto-management check runs at the end of every import.\nWhen false, no redundancy check is triggered on import; the check can\nstill be invoked manually via the API."
+ },
+ "AllowDeletion": {
+ "title": "Allow Deletion",
+ "type": "boolean",
+ "description": "When true, redundant release candidates are automatically deleted.\nWhen false, the check still runs but only logs what would be removed\n(preview/display mode). Requires AutoDeleteOnImport to\nbe true for the deletion to trigger automatically on import."
+ },
+ "PerFileDeletionForAiringSeries": {
+ "title": "Per File Deletion For Airing Series",
+ "type": "boolean",
+ "description": "When true and a series is still airing, redundancy is evaluated per-file\nrather than per-candidate. Individual files whose episode coverage is\nalready provided by a higher-ranked candidate are deleted, while the\nremaining files in that candidate (covering episodes the primary has not\nyet reached) are retained.\n \nWhen false (or when the series has finished airing), the existing\nwhole-candidate rule applies: a secondary candidate is only deleted if\nits entire episode coverage is already subsumed by the primary."
+ },
+ "EpisodeTypeScope": {
+ "title": "Episode Type Scope",
+ "description": "Controls how episode coverage is measured for mixed-type releases\n(releases that contain both regular episodes and specials).",
+ "$ref": "#/definitions/EpisodeTypeScope",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "KeepTogether",
+ "aliasValues": ""
+ },
+ {
+ "value": "BestPerType",
+ "aliasValues": ""
+ }
+ ]
+ }
+ }
+ }
+ },
+ "ReleaseSignalType": {
+ "type": "string",
+ "description": "Signals available for sequential release comparison.",
+ "x-enumNames": [
+ "Source",
+ "Resolution",
+ "VideoCodec",
+ "BitDepth",
+ "AudioStreams",
+ "SubtitleStreams",
+ "AudioCodec",
+ "Chaptered",
+ "SubGroup",
+ "Version",
+ "Corrupted",
+ "Censored",
+ "Creditless",
+ "GroupHomogeneity",
+ "AudioLanguage",
+ "SubtitleLanguage"
+ ],
+ "enum": [
+ "Source",
+ "Resolution",
+ "VideoCodec",
+ "BitDepth",
+ "AudioStreams",
+ "SubtitleStreams",
+ "AudioCodec",
+ "Chaptered",
+ "SubGroup",
+ "Version",
+ "Corrupted",
+ "Censored",
+ "Creditless",
+ "GroupHomogeneity",
+ "AudioLanguage",
+ "SubtitleLanguage"
+ ]
+ },
+ "EpisodeTypeScope": {
+ "type": "string",
+ "description": "Controls whether releases covering mixed episode types (regular + specials)\nare treated as a single unit or ranked independently per type.",
+ "x-enumNames": [
+ "KeepTogether",
+ "BestPerType"
+ ],
+ "enum": [
+ "KeepTogether",
+ "BestPerType"
+ ]
+ },
+ "Logging": {
+ "type": "object",
+ "properties": {
+ "RotationEnabled": {
+ "title": "Use Log Rotation",
+ "type": "boolean",
+ "description": "Indicates that the log rotation should be used.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "LOGGING_ROTATION_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "RotationCompress": {
+ "title": "Rotation Compress",
+ "type": "boolean",
+ "description": "Indicates that we should compress the log files.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "LOGGING_ROTATION_COMPRESS",
+ "envVarOverridable": false
+ }
+ },
+ "RotationDeleteEnabled": {
+ "title": "Rotation Delete Enabled",
+ "type": "boolean",
+ "description": "Indicates that we should delete older log files.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "LOGGING_ROTATION_DELETE_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "RotationDeleteDays": {
+ "title": "Keep period (days)",
+ "type": [
+ "integer",
+ "null"
+ ],
+ "description": "Number of days to keep log files before deleting.",
+ "format": "int32",
+ "maximum": 2147483647.0,
+ "minimum": 0.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "LOGGING_ROTATION_DELETE_DAYS",
+ "envVarOverridable": false
+ }
+ },
+ "TraceLog": {
+ "title": "Enable Trace Logging",
+ "type": "boolean",
+ "description": "Enable trace logging in the log file and web UI live console.",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "SHOKO_TRACE_LOG",
+ "envVarOverridable": false
+ }
+ },
+ "DefaultFileLogLevel": {
+ "title": "Default File Log Level",
+ "description": "Default log level for file output.",
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/definitions/LogLevel"
+ }
+ ],
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "envVar": "LOGGING_FILE_LOG_LEVEL",
+ "envVarOverridable": true,
+ "enumDefinitions": [
+ {
+ "value": "Trace",
+ "aliasValues": ""
+ },
+ {
+ "value": "Debug",
+ "aliasValues": ""
+ },
+ {
+ "value": "Information",
+ "aliasValues": ""
+ },
+ {
+ "value": "Warning",
+ "aliasValues": ""
+ },
+ {
+ "value": "Error",
+ "aliasValues": ""
+ },
+ {
+ "value": "Critical",
+ "aliasValues": ""
+ },
+ {
+ "value": "None",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "DefaultSignalRLogLevel": {
+ "title": "Default SignalR Log Level",
+ "description": "Default log level for SignalR output.",
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/definitions/LogLevel"
+ }
+ ],
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "envVar": "LOGGING_SIGNALR_LOG_LEVEL",
+ "envVarOverridable": true,
+ "enumDefinitions": [
+ {
+ "value": "Trace",
+ "aliasValues": ""
+ },
+ {
+ "value": "Debug",
+ "aliasValues": ""
+ },
+ {
+ "value": "Information",
+ "aliasValues": ""
+ },
+ {
+ "value": "Warning",
+ "aliasValues": ""
+ },
+ {
+ "value": "Error",
+ "aliasValues": ""
+ },
+ {
+ "value": "Critical",
+ "aliasValues": ""
+ },
+ {
+ "value": "None",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "DefaultConsoleLogLevel": {
+ "title": "Default Console Log Level",
+ "description": "Default log level for console output.",
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/definitions/LogLevel"
+ }
+ ],
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "envVar": "LOGGING_CONSOLE_LOG_LEVEL",
+ "envVarOverridable": true,
+ "enumDefinitions": [
+ {
+ "value": "Trace",
+ "aliasValues": ""
+ },
+ {
+ "value": "Debug",
+ "aliasValues": ""
+ },
+ {
+ "value": "Information",
+ "aliasValues": ""
+ },
+ {
+ "value": "Warning",
+ "aliasValues": ""
+ },
+ {
+ "value": "Error",
+ "aliasValues": ""
+ },
+ {
+ "value": "Critical",
+ "aliasValues": ""
+ },
+ {
+ "value": "None",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "ConsoleFormat": {
+ "title": "Console Format",
+ "description": "Console layout format for runtime logs.",
+ "default": "console",
+ "$ref": "#/definitions/LogSerializeFormat",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "envVar": "LOGGING_CONSOLE_FORMAT",
+ "envVarOverridable": false,
+ "enumDefinitions": [
+ {
+ "value": "simple",
+ "aliasValues": ""
+ },
+ {
+ "value": "full",
+ "aliasValues": ""
+ },
+ {
+ "value": "json",
+ "aliasValues": ""
+ },
+ {
+ "value": "legacy",
+ "aliasValues": ""
+ },
+ {
+ "value": "console",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "LogLevelRules": {
+ "title": "Log Level Rules",
+ "type": "array",
+ "description": "Optional user-defined log level override rules keyed by logger pattern.",
+ "items": {
+ "$ref": "#/definitions/LogLevelRule"
+ },
+ "x-uiDefinition": {
+ "elementType": "list",
+ "requiresRestart": false,
+ "envVar": "LOGGING_LOG_LEVEL_RULES",
+ "envVarOverridable": true
+ }
+ }
+ }
+ },
+ "LogLevel": {
+ "type": "string",
+ "description": "Defines logging severity levels.",
+ "x-enumNames": [
+ "Trace",
+ "Debug",
+ "Information",
+ "Warning",
+ "Error",
+ "Critical",
+ "None"
+ ],
+ "enum": [
+ "Trace",
+ "Debug",
+ "Information",
+ "Warning",
+ "Error",
+ "Critical",
+ "None"
+ ]
+ },
+ "LogSerializeFormat": {
+ "type": "string",
+ "description": "Serialization layout for a LogEntry and for log downloads.\n ",
+ "x-enumNames": [
+ "Simple",
+ "Full",
+ "Json",
+ "Legacy",
+ "Console"
+ ],
+ "enum": [
+ "simple",
+ "full",
+ "json",
+ "legacy",
+ "console"
+ ]
+ },
+ "LogLevelRule": {
+ "type": "object",
+ "description": "Configuration for per-logger max level override rules.",
+ "required": [
+ "LoggerNamePattern"
+ ],
+ "properties": {
+ "Key": {
+ "title": "Key",
+ "type": "string",
+ "default": "New Log Level Rule"
+ },
+ "LoggerNamePattern": {
+ "title": "Pattern",
+ "type": "string",
+ "description": "Logger name pattern targeted by this rule.",
+ "default": "",
+ "minLength": 1
+ },
+ "MaxLevel": {
+ "title": "Max Level",
+ "description": "Optional max level for this logger rule.",
+ "default": "Information",
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/definitions/LogLevel"
+ }
+ ],
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "Trace",
+ "aliasValues": ""
+ },
+ {
+ "value": "Debug",
+ "aliasValues": ""
+ },
+ {
+ "value": "Information",
+ "aliasValues": ""
+ },
+ {
+ "value": "Warning",
+ "aliasValues": ""
+ },
+ {
+ "value": "Error",
+ "aliasValues": ""
+ },
+ {
+ "value": "Critical",
+ "aliasValues": ""
+ },
+ {
+ "value": "None",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "Final": {
+ "title": "Final",
+ "type": "boolean",
+ "description": "Whether this rule should stop processing later rules.",
+ "default": true
+ }
+ }
+ },
+ "Linux": {
+ "type": "object",
+ "properties": {
+ "UID": {
+ "title": "UID",
+ "type": "integer",
+ "format": "int32"
+ },
+ "GID": {
+ "title": "GID",
+ "type": "integer",
+ "format": "int32"
+ },
+ "Permission": {
+ "title": "Permission",
+ "type": "integer",
+ "format": "int32"
+ }
+ }
+ },
+ "Web": {
+ "type": "object",
+ "description": "Configure settings related to the HTTP(S) hosting.",
+ "properties": {
+ "Port": {
+ "title": "Server Port",
+ "type": "integer",
+ "description": "The port to listen on.",
+ "default": 8111,
+ "maximum": 65535.0,
+ "minimum": 1.0,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_PORT",
+ "envVarOverridable": false
+ }
+ },
+ "AutoReplaceWebUIWithIncluded": {
+ "title": "Auto Replace Web UI With Included Version",
+ "type": "boolean",
+ "description": "Automagically replace the current web ui with the included version if\nthe current version is older then the included version.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_WEBUI_AUTO_REPLACE",
+ "envVarOverridable": false
+ }
+ },
+ "EnableWebUI": {
+ "title": "Enable Web UI",
+ "type": "boolean",
+ "description": "Enable the Web UI. Disabling this will run the server in \"headless\"\nmode.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_WEBUI_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "WebUIPrefix": {
+ "title": "Web UI Prefix",
+ "type": "string",
+ "description": "The public path prefix for where to mount the Web UI.",
+ "default": "webui",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_WEBUI_PREFIX",
+ "envVarOverridable": false
+ }
+ },
+ "WebUIPath": {
+ "title": "Web UI Path",
+ "type": "string",
+ "description": "A relative path from the DataPath\nto where the Web UI is installed, or an absolute path if you have it\nsomewhere else. Will be used to populate the\nWebPath field.",
+ "default": "webui",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_WEBUI_PATH",
+ "envVarOverridable": false
+ }
+ },
+ "EnableSwaggerUI": {
+ "title": "Enable Swagger UI",
+ "type": "boolean",
+ "description": "Enable the Swagger UI.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_SWAGGER_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "SwaggerUIPrefix": {
+ "title": "Swagger UI Prefix",
+ "type": "string",
+ "description": "The public path prefix for where to mount the Swagger UI.",
+ "default": "swagger",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_SWAGGER_PREFIX",
+ "envVarOverridable": false
+ }
+ },
+ "EnableIndexRedirect": {
+ "title": "Enable Index Redirect",
+ "type": "boolean",
+ "description": "Enable the built-in index redirect available at `/`, redirecting\nthe user to the .\n ",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_API_INDEX_REDIRECT_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "EnableSignalR": {
+ "title": "Enable SignalR",
+ "type": "boolean",
+ "description": "Enable the built-in SignalR hubs available at `/signalr`.\n ",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_API_SIGNALR_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "EnableAPIv1": {
+ "title": "Enable API v1",
+ "type": "boolean",
+ "description": "Enable the deprecated API v1 endpoints.",
+ "default": false,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_API_V1_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "EnableAPIv2": {
+ "title": "Enable API v2",
+ "type": "boolean",
+ "description": "Enable the API v2 endpoints.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_API_V2_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "EnableAPIv3": {
+ "title": "Enable API v3",
+ "type": "boolean",
+ "description": "Enable the API v3 endpoints.",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_API_V3_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "EnableLegacyPlexAPI": {
+ "title": "Enable Legacy Plex API",
+ "type": "boolean",
+ "description": "Enable the built-in legacy Plex API available at `/plex`, once\npart of APIv2, but separated so that it can be toggled separately.\n ",
+ "default": true,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_API_PLEX_LEGACY_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "AllowAnonymousFileStreamingInAPIv3": {
+ "title": "Allow Anonymous File Streaming in API v3",
+ "type": "boolean",
+ "description": "Allow anonymous file streaming in API v3.",
+ "default": false,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_API_V3_ALLOW_ANONYMOUS_FILE_STREAMING",
+ "envVarOverridable": false
+ }
+ },
+ "AlwaysUseDeveloperExceptions": {
+ "title": "Always Use Developer Exceptions",
+ "type": "boolean",
+ "description": "Always use the developer exceptions page, even in production.",
+ "default": false,
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "SHOKO_WEB_DEVELOPER_EXCEPTIONS",
+ "envVarOverridable": false
+ }
+ },
+ "ClientManifestUrl": {
+ "title": "Client Manifest URL",
+ "type": "string",
+ "description": "The manifest URL for the Web UI component updates.",
+ "default": "https://raw.githubusercontent.com/ShokoAnime/Shoko-WebUI/metadata/manifest.json",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "SHOKO_CLIENT_MANIFEST_URL",
+ "envVarOverridable": false
+ }
+ },
+ "ServerManifestUrl": {
+ "title": "Server Manifest URL",
+ "type": "string",
+ "description": "The manifest URL for the server updates.",
+ "default": "https://raw.githubusercontent.com/ShokoAnime/ShokoServer/metadata/manifest.json",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": false,
+ "envVar": "SHOKO_SERVER_MANIFEST_URL",
+ "envVarOverridable": false
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shoko.Tests/Data/Configuration/SystemTextJsonTwin.schema.golden.json b/Shoko.Tests/Data/Configuration/SystemTextJsonTwin.schema.golden.json
new file mode 100644
index 0000000000..2a8cee95bd
--- /dev/null
+++ b/Shoko.Tests/Data/Configuration/SystemTextJsonTwin.schema.golden.json
@@ -0,0 +1,262 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "title": "System Text Json Twin",
+ "type": "object",
+ "properties": {
+ "Body": {
+ "title": "Body",
+ "$ref": "#/definitions/TwinBody"
+ }
+ },
+ "definitions": {
+ "TwinBody": {
+ "title": "Twin Body",
+ "type": "object",
+ "properties": {
+ "Name": {
+ "title": "Display Name",
+ "type": "string",
+ "default": "shoko"
+ },
+ "Enabled": {
+ "title": "Enabled",
+ "type": "boolean",
+ "x-uiDefinition": {
+ "elementType": "auto",
+ "requiresRestart": true,
+ "envVar": "TWIN_ENABLED",
+ "envVarOverridable": false
+ }
+ },
+ "Count": {
+ "title": "Count",
+ "type": "integer",
+ "format": "int32",
+ "maximum": 100.0,
+ "minimum": 1.0
+ },
+ "Ratio": {
+ "title": "Ratio",
+ "type": "number",
+ "format": "double"
+ },
+ "Mode": {
+ "title": "Mode",
+ "$ref": "#/definitions/TwinMode",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "slow-and-steady",
+ "aliasValues": ""
+ },
+ {
+ "value": "balanced",
+ "aliasValues": ""
+ },
+ {
+ "value": "fast",
+ "aliasValues": ""
+ }
+ ]
+ }
+ },
+ "Modes": {
+ "title": "Modes",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TwinMode"
+ }
+ },
+ "Secret": {
+ "title": "Secret",
+ "type": "string"
+ },
+ "Note": {
+ "title": "Note",
+ "type": "string"
+ },
+ "Script": {
+ "title": "Script",
+ "type": "string"
+ },
+ "Endpoints": {
+ "title": "Endpoints",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TwinEndpoint"
+ }
+ },
+ "Weights": {
+ "title": "Weights",
+ "type": "object",
+ "x-dictionaryKey": {
+ "$ref": "#/definitions/TwinMode"
+ },
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "Toggles": {
+ "title": "Toggles",
+ "type": "object",
+ "additionalProperties": {
+ "type": "boolean"
+ }
+ },
+ "Picked": {
+ "title": "Picked",
+ "$ref": "#/definitions/SelectComponent_1"
+ }
+ }
+ },
+ "TwinMode": {
+ "type": "string",
+ "description": "",
+ "x-enumNames": [
+ "Slow",
+ "Balanced",
+ "Fast"
+ ],
+ "x-enum-descriptions": [
+ "Takes its time.",
+ null,
+ null
+ ],
+ "enum": [
+ "slow-and-steady",
+ "balanced",
+ "fast"
+ ]
+ },
+ "TwinEndpoint": {
+ "type": "object",
+ "properties": {
+ "ID": {
+ "title": "ID",
+ "type": "string"
+ },
+ "Url": {
+ "title": "Url",
+ "type": "string",
+ "format": "uri"
+ },
+ "Mode": {
+ "title": "Mode",
+ "$ref": "#/definitions/TwinMode",
+ "x-uiDefinition": {
+ "elementType": "enum",
+ "requiresRestart": false,
+ "enumDefinitions": [
+ {
+ "value": "slow-and-steady",
+ "aliasValues": ""
+ },
+ {
+ "value": "balanced",
+ "aliasValues": ""
+ },
+ {
+ "value": "fast",
+ "aliasValues": ""
+ }
+ ]
+ }
+ }
+ }
+ },
+ "SelectComponent_1": {
+ "type": "object",
+ "description": "A select component for the UI.\n ",
+ "required": [
+ "options",
+ "groups"
+ ],
+ "properties": {
+ "options": {
+ "type": "array",
+ "description": "The options for the select component in the UI.\n ",
+ "default": [],
+ "items": {
+ "$ref": "#/definitions/SelectOption_1"
+ }
+ },
+ "groups": {
+ "type": "array",
+ "description": "The groups for the select component in the UI.\n ",
+ "default": [],
+ "items": {
+ "$ref": "#/definitions/SelectGroup"
+ }
+ }
+ }
+ },
+ "SelectOption_1": {
+ "type": "object",
+ "description": "A select option for the UI.\n ",
+ "required": [
+ "value"
+ ],
+ "properties": {
+ "label": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "The label for the option.\n "
+ },
+ "groupId": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "description": "The unique identifier for the group this option belongs to, or\n`null` if it should be rendered outside of a group.\n "
+ },
+ "value": {
+ "type": "string",
+ "description": "The value of the option.\n "
+ },
+ "selected": {
+ "type": "boolean",
+ "description": "Whether the option is selected.\n ",
+ "default": false
+ },
+ "default": {
+ "type": "boolean",
+ "description": "Whether the option is the default.\n ",
+ "default": false
+ },
+ "disabled": {
+ "type": "boolean",
+ "description": "Whether the option is disabled.\n ",
+ "default": false
+ }
+ }
+ },
+ "SelectGroup": {
+ "type": "object",
+ "description": "A select group for the UI.\n ",
+ "required": [
+ "label",
+ "disabled"
+ ],
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "The unique identifier for the group.\n "
+ },
+ "label": {
+ "type": "string",
+ "description": "The label for the group.\n "
+ },
+ "disabled": {
+ "type": "boolean",
+ "description": "Whether the group is disabled.\n ",
+ "default": false
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shoko.Tests/Services/ActionParameterValidationTests.cs b/Shoko.Tests/Services/ActionParameterValidationTests.cs
new file mode 100644
index 0000000000..32014a5b6d
--- /dev/null
+++ b/Shoko.Tests/Services/ActionParameterValidationTests.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Newtonsoft.Json;
+using NJsonSchema;
+using Shoko.Abstractions.Actions;
+using Shoko.Abstractions.Plugin;
+using Shoko.Server.Actions;
+using Shoko.Server.Services;
+using Shoko.Server.Services.Configuration;
+using Xunit;
+
+namespace Shoko.Tests.Services;
+
+///
+/// Coverage for the checks an invocation payload goes through: the schema
+///
+/// produces, and the population that follows it.
+///
+///
+/// ActionService.ValidateParameters is exactly these two pieces
+/// composed — the action's schema plus
+/// IConfigurationService.Validate(json, schema) — so they are driven
+/// directly here. The repository has no controller-level test harness, and
+/// standing one up would exercise ASP.NET's body binding rather than any of
+/// this.
+///
+[Collection(ConfigurationSchemaCollection.Name)]
+public class ActionParameterValidationTests
+{
+ private static ConfigurationService CreateConfigurationService()
+ {
+ var applicationPaths = new Mock();
+ applicationPaths.SetupGet(x => x.DataPath).Returns(Path.GetTempPath());
+ applicationPaths.SetupGet(x => x.ConfigurationsPath).Returns(Path.GetTempPath());
+ return new ConfigurationService(NullLoggerFactory.Instance, applicationPaths.Object, Mock.Of());
+ }
+
+ private static JsonSchema SchemaFor() where TAction : IExecutableAction
+ => ShokoJsonSchemaGeneratorGoldenTests.CreateGenerator().GetSchemaForActionParameters(typeof(TAction)).Schema;
+
+ private static IReadOnlyDictionary> Validate(string json) where TAction : IExecutableAction
+ => CreateConfigurationService().Validate(json, SchemaFor());
+
+ [Fact]
+ public void TheSchema_ClosesTheObjectSoATypoCannotSlipThrough()
+ {
+ // The generator leaves a configuration's objects open, which is right
+ // for a document read back from disk and wrong for an invocation
+ // payload.
+ var schema = SchemaFor();
+
+ Assert.False(schema.AllowAdditionalProperties);
+ Assert.Contains("\"additionalProperties\": false", schema.ToJson(), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void AMistypedParameterName_IsRejectedRatherThanIgnored()
+ {
+ // The whole reason this validation is worth having: `PopulateObject`
+ // ignores a member it cannot map, so without the check the action would
+ // run on its defaults and report success.
+ var errors = Validate("""{"Quury": "hello"}""");
+
+ // Keyed by the offending path, the same way a rejected configuration
+ // body is. The bracket notation is NJsonSchema's own for an
+ // additional-property error and is shared with the configuration
+ // endpoints, so it is pinned here rather than reshaped.
+ var (path, messages) = Assert.Single(errors);
+ Assert.Equal("['Quury']", path);
+ Assert.Equal(["No Additional Properties Allowed"], messages);
+ }
+
+ [Fact]
+ public void AMetadataFieldInTheBody_IsRejected()
+ {
+ // `Name` and `Permission` are not parameters, so the schema does not
+ // list them and the closed object turns them into errors.
+ Assert.NotEmpty(Validate("""{"Name": "hijacked"}"""));
+ Assert.NotEmpty(Validate("""{"Permission": "User"}"""));
+ Assert.NotEmpty(Validate("""{"Query": "fine", "Category": "Import"}"""));
+ }
+
+ [Fact]
+ public void APartialBody_IsAccepted()
+ {
+ // Nothing is required of an invocation payload: the instance is already
+ // built with its own defaults, so supplying one parameter must not
+ // oblige the caller to supply the rest.
+ Assert.Empty(Validate("""{"Query": "hello"}"""));
+ Assert.Empty(Validate("{}"));
+ Assert.Empty(Validate("""{"Force": true}"""));
+ Assert.Empty(Validate("""{"RemoveShowLinks": false}"""));
+ }
+
+ [Fact]
+ public void TheSchema_RequiresNothing()
+ {
+ var schema = SchemaFor();
+
+ Assert.Empty(schema.RequiredProperties);
+ Assert.All(schema.ActualProperties.Values, x => Assert.False(x.IsRequired));
+ }
+
+ [Fact]
+ public void AWrongTypedValue_IsRejected()
+ {
+ Assert.NotEmpty(Validate("""{"MaxResults": "lots"}"""));
+ Assert.NotEmpty(Validate("""{"Mode": "sideways"}"""));
+ }
+
+ [Fact]
+ public void AnOutOfRangeValue_IsRejected()
+ {
+ // `[Range(1, 100)]` reaches the schema, so the bound is enforced on the
+ // way in and not only rendered in the form.
+ Assert.NotEmpty(Validate("""{"MaxResults": 0}"""));
+ Assert.NotEmpty(Validate("""{"MaxResults": 1000}"""));
+ Assert.Empty(Validate("""{"MaxResults": 50}"""));
+ }
+
+ [Fact]
+ public void AFullyPopulatedBody_RoundTripsOntoTheAction()
+ {
+ var action = new ParameterisedGlobalAction();
+ var parameters = new Dictionary
+ {
+ ["Query"] = "shoko",
+ ["Mode"] = "fast",
+ ["MaxResults"] = 7,
+ ["Tags"] = new List { "a", "b" },
+ ["DryRun"] = true,
+ };
+
+ Assert.Empty(Validate(JsonConvert.SerializeObject(parameters)));
+ ActionService.PopulateParameters(action, parameters);
+
+ Assert.Equal("shoko", action.Query);
+ Assert.Equal(TwinMode.Fast, action.Mode);
+ Assert.Equal(7, action.MaxResults);
+ Assert.Equal(["a", "b"], action.Tags);
+ Assert.True(action.DryRun);
+ }
+
+ [Fact]
+ public void PopulationCannotWriteTheMetadataSurface()
+ {
+ // Belt and braces: validation rejects such a body before it gets here,
+ // but population uses the same contract resolver that hides the
+ // metadata from the schema, so the members are not writable even if a
+ // payload reaches this point unchecked.
+ var action = new SettableMetadataAction();
+ var parameters = new Dictionary
+ {
+ ["Name"] = "hijacked",
+ ["Description"] = "hijacked",
+ ["RequiresConfirmation"] = false,
+ ["Force"] = true,
+ };
+
+ ActionService.PopulateParameters(action, parameters);
+
+ Assert.Equal("Settable Metadata", action.Name);
+ Assert.Equal("Not a parameter.", action.Description);
+ Assert.True(action.RequiresConfirmation);
+ // The one genuine parameter still lands.
+ Assert.True(action.Force);
+ }
+
+ [Fact]
+ public void ANestedParameterObject_IsClosedToo()
+ {
+ // A typo one level down is just as silent as one at the top.
+ Assert.Empty(Validate("""{"Options": {"Force": true, "Depth": 3}}"""));
+ Assert.NotEmpty(Validate("""{"Options": {"Frce": true}}"""));
+ }
+
+ [Fact]
+ public void ADictionaryParameter_StaysOpen()
+ {
+ // A dictionary carries its value type in `additionalProperties`, so
+ // closing it would throw the value schema away and reject every entry.
+ Assert.Empty(Validate("""{"Weights": {"anything": 3, "at-all": 7}}"""));
+ // The value type is still enforced.
+ Assert.NotEmpty(Validate("""{"Weights": {"anything": "three"}}"""));
+ }
+
+ [Fact]
+ public void AnActionWithSettableMetadata_StillDoesNotListItAsAParameter()
+ {
+ var schema = SchemaFor();
+
+ Assert.Equal(["Force"], schema.ActualProperties.Keys);
+ Assert.NotEmpty(Validate("""{"Name": "hijacked"}"""));
+ }
+}
diff --git a/Shoko.Tests/Services/ActionUiDefinitionBuilderTests.cs b/Shoko.Tests/Services/ActionUiDefinitionBuilderTests.cs
new file mode 100644
index 0000000000..663ad81c3e
--- /dev/null
+++ b/Shoko.Tests/Services/ActionUiDefinitionBuilderTests.cs
@@ -0,0 +1,295 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Extensions.Logging.Abstractions;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Serialization;
+using Shoko.Abstractions.UI;
+using Shoko.Abstractions.UI.Elements;
+using Shoko.Abstractions.UI.Enums;
+using Shoko.Server.Actions;
+using Shoko.Server.Services.Configuration;
+using Xunit;
+
+namespace Shoko.Tests.Services;
+
+///
+/// Coverage for the action-parameter entrypoint: an executable action's
+/// settable, serialized properties are described by the very same
+/// a configuration is, and the action's own
+/// metadata surface is not part of it.
+///
+[Collection(ConfigurationSchemaCollection.Name)]
+public class ActionUiDefinitionBuilderTests
+{
+ private static readonly ActionUiDefinitionBuilder _builder = new(NullLoggerFactory.Instance);
+
+ private static UiDefinition BuildFor(Type actionType)
+ {
+ var described = _builder.Build(Guid.Empty, "Action", null, actionType);
+ Assert.NotNull(described);
+ return described.Definition;
+ }
+
+ private static UiSectionContainerElement RootOf(Type actionType)
+ => Assert.IsType(BuildFor(actionType).Root);
+
+ ///
+ /// Every member of the action's metadata surface, derived the same way
+ /// the exclusion rule derives it rather than written out here.
+ ///
+ public static TheoryData MetadataMemberNames()
+ {
+ var data = new TheoryData();
+ foreach (var name in ActionMetadataContractResolver.MetadataMembers.Keys)
+ data.Add(name);
+ return data;
+ }
+
+ [Fact]
+ public void MetadataSurface_IsExactlyTheKnownSeven()
+ {
+ // Pins what the mechanical rule actually resolves to, so a member added
+ // to `IExecutableAction` or to a scoped base shows up here rather than
+ // silently becoming a parameter.
+ Assert.Equal(
+ ["Category", "ConfirmationMessage", "Description", "Name", "Permission", "RequiresConfirmation", "Scope"],
+ ActionMetadataContractResolver.MetadataMembers.Keys.Order(StringComparer.Ordinal)
+ );
+ }
+
+ [Theory]
+ [MemberData(nameof(MetadataMemberNames))]
+ public void MetadataSurface_IsNeverAParameter(string memberName)
+ {
+ foreach (var actionType in new[] { typeof(ParameterisedGlobalAction), typeof(ParameterisedSeriesAction) })
+ {
+ var root = RootOf(actionType);
+ Assert.DoesNotContain(root.Items, x => string.Equals(x.Key, memberName, StringComparison.Ordinal));
+ Assert.DoesNotContain(root.Structure, x => string.Equals(x.Name, memberName, StringComparison.Ordinal));
+ }
+ }
+
+ [Fact]
+ public void Parameters_AreDescribedInTheAuthoredOrder()
+ {
+ var root = RootOf(typeof(ParameterisedGlobalAction));
+
+ Assert.Equal(["Query", "Mode", "MaxResults", "Tags", "DryRun"], root.Items.Keys);
+ Assert.Equal(root.Items.Keys, root.Structure.Select(x => x.Name));
+ Assert.All(root.Structure, x => Assert.Equal(UiStructureMemberKind.Item, x.Kind));
+ }
+
+ [Fact]
+ public void Parameters_CarryTheSameElementKindsAConfigurationWould()
+ {
+ var root = RootOf(typeof(ParameterisedGlobalAction));
+
+ var query = Assert.IsType(root.Items["Query"]);
+ Assert.Equal("Search Query", query.Label);
+ Assert.Equal("New", query.Badge?.Name);
+ Assert.Equal(DisplayColorTheme.Primary, query.Badge?.Theme);
+
+ var mode = Assert.IsType(root.Items["Mode"]);
+ Assert.Equal(["slow-and-steady", "balanced", "fast"], mode.Values.Select(x => x.Value));
+ Assert.Equal("Behaviour", mode.SectionName);
+
+ var maxResults = Assert.IsType(root.Items["MaxResults"]);
+ Assert.Equal(1L, maxResults.Minimum);
+ Assert.Equal(100L, maxResults.Maximum);
+ Assert.Equal(DisplayElementSize.Small, maxResults.Size);
+ Assert.True(maxResults.Visibility.Advanced);
+ Assert.Equal("DryRun", maxResults.Visibility.Toggle?.Path);
+ Assert.Equal(DisplayVisibility.ReadOnly, maxResults.Visibility.Toggle?.Visibility);
+
+ var tags = Assert.IsType(root.Items["Tags"]);
+ Assert.True(tags.UniqueItems);
+ Assert.IsType(tags.Item);
+
+ Assert.IsType(root.Items["DryRun"]);
+ }
+
+ [Fact]
+ public void Parameters_AreDescribedByTheNewtonsoftPath()
+ {
+ // An action never implements `INewtonsoftJsonConfiguration`, yet
+ // `JsonConvert.PopulateObject` is what fills it in, so the entrypoint
+ // has to take the Newtonsoft path unconditionally. `TwinMode` carries
+ // both serialisers' naming attributes in agreement, so the tell is the
+ // aliases: only the Newtonsoft path resolves `[EnumMember]`.
+ var mode = Assert.IsType(RootOf(typeof(ParameterisedGlobalAction)).Items["Mode"]);
+
+ Assert.Equal(["Slow", "Balanced", "Very Fast"], mode.Values.Select(x => x.Title));
+ Assert.Equal("\"balanced\"", JsonConvert.SerializeObject(TwinMode.Balanced));
+ }
+
+ [Fact]
+ public void ScopedAction_DropsScopeButKeepsItsOwnParameters()
+ {
+ var root = RootOf(typeof(ParameterisedSeriesAction));
+
+ // `Scope` is declared on the base class and no interface names it, and
+ // the entity context is a protected property Newtonsoft never sees.
+ Assert.Equal(["Force"], root.Items.Keys);
+ Assert.DoesNotContain(root.Items, x => x.Key is "Series");
+ }
+
+ [Fact]
+ public void ScopedContext_IsNotAPublicPropertyToBeginWith()
+ {
+ // The exclusion rule leans on this: if the context were public, it
+ // would need naming, and naming it would clobber a legitimate `Series`
+ // parameter.
+ Assert.DoesNotContain(
+ typeof(ParameterisedSeriesAction).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance),
+ x => x.Name is "Series"
+ );
+ Assert.DoesNotContain(ActionMetadataContractResolver.MetadataMembers.Keys, x => x is "Series" or "Group" or "Episode" or "Video");
+ }
+
+ [Fact]
+ public void AParameterThatMerelySharesAMetadataName_IsKept()
+ {
+ var root = RootOf(typeof(ShadowedNameAction));
+
+ // `Name` here is an `int` parameter; `IExecutableAction.Name` is
+ // implemented explicitly and is a `string`. The rule matches on name
+ // and type, so only the latter is dropped.
+ var (key, element) = Assert.Single(root.Items);
+ Assert.Equal("Name", key);
+ Assert.IsType(element);
+ }
+
+ [Fact]
+ public void AnActionWhoseParametersCannotBeDescribed_FailsRatherThanDegrading()
+ {
+ // An action that declares parameters has to be describable. Swallowing
+ // this would leave it listed but uninvokable from a UI, which is worse
+ // than the startup failure the equivalent configuration would cause.
+ Assert.ThrowsAny(() => _builder.Build(Guid.Empty, "Undescribable", null, typeof(UndescribableGlobalAction)));
+ }
+
+ [Fact]
+ public void AnActionWithoutParameters_HasNoDefinitionAtAll()
+ {
+ Assert.Null(_builder.Build(Guid.Empty, "Run Import", null, typeof(ParameterlessGlobalAction)));
+ }
+
+ [Fact]
+ public void TheRoot_CarriesTheActionsIdentityAndNoSaveAction()
+ {
+ var id = Guid.NewGuid();
+ var described = _builder.Build(id, "Reindex Library", "Rebuilds the search index.", typeof(ParameterisedGlobalAction));
+
+ Assert.NotNull(described);
+ var definition = described.Definition;
+ Assert.Equal(id, definition.ID);
+ Assert.Equal("Reindex Library", definition.Name);
+ Assert.Equal("Rebuilds the search index.", definition.Description);
+ // There is nothing to save on an invocation form.
+ Assert.False(Assert.IsType(definition.Root).ShowSaveAction);
+ }
+
+ [Fact]
+ public void TheDefinition_IsShapedExactlyLikeAConfigurations()
+ {
+ // The binding requirement: a client cannot tell which entrypoint
+ // produced the document. Compare the serialised key sets rather than
+ // the values.
+ var action = Serialize(BuildFor(typeof(ParameterisedGlobalAction)));
+ var configuration = Serialize(
+ new UiDefinitionBuilder(NullLogger.Instance)
+ .Build(Guid.Empty, "Twin", null, ShokoJsonSchemaGeneratorGoldenTests.CreateGenerator().GetSchemaForType(typeof(NewtonsoftTwinConfiguration)))
+ );
+
+ Assert.Equal(TopLevelKeys(configuration), TopLevelKeys(action));
+ Assert.DoesNotContain("ConfigurationID", action, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void NoElement_ComesOutUnknownOrPathless()
+ {
+ var definition = BuildFor(typeof(ParameterisedGlobalAction));
+ var elements = Flatten(definition.Root).ToList();
+
+ Assert.DoesNotContain(elements, x => x is UiUnknownElement);
+ Assert.All(elements, x => Assert.NotEqual(UiElementKind.Unknown, x.Kind));
+ // A client indexes into `Items` by the key it sees in `Structure`, so
+ // the map's key and the element's own key have to agree.
+ Assert.NotEmpty(elements.OfType().SelectMany(x => x.Items));
+ Assert.All(
+ elements.OfType(),
+ container => Assert.All(container.Items, entry => Assert.Equal(entry.Key, entry.Value.Key))
+ );
+ }
+
+ [Fact]
+ public void RealInTreeActions_AreDescribedWithoutTheirMetadata()
+ {
+ // `DownloadAllImagesAction` and `PurgeAllTmdbLinksAction` are the only
+ // two in-tree actions that declare parameters, and between them they
+ // cover nullable enums, plain bools and a nullable bool.
+ var images = RootOf(typeof(DownloadAllImagesAction));
+ Assert.Equal(["ImageSource", "ImageType", "XrefSource", "Force"], images.Items.Keys);
+ Assert.All(images.Items.Values.Take(3), x => Assert.True(Assert.IsType(x).IsNullable));
+ Assert.IsType(images.Items["Force"]);
+
+ var links = RootOf(typeof(PurgeAllTmdbLinksAction));
+ Assert.Equal(["RemoveShowLinks", "RemoveMovieLinks", "ResetAutoLinkingState"], links.Items.Keys);
+ Assert.All(links.Items.Values, x => Assert.IsType(x));
+ // Exactly as for a configuration, a default comes off `[DefaultValue]`
+ // and not off a property initialiser, so these two carry none even
+ // though both initialise to `true`.
+ Assert.All(links.Items.Values, x => Assert.Null(x.Default));
+ Assert.True(links.Items["ResetAutoLinkingState"].IsNullable);
+ }
+
+ [Fact]
+ public void ActionDefinitions_AreDumpedNextToTheConfigurationOnes()
+ {
+ var outputDirectory = TestPaths.OutputDirectory;
+ Directory.CreateDirectory(outputDirectory);
+
+ // The fixture covers one of every decorated element; the in-tree action
+ // shows what an undecorated, real one comes out as.
+ var fixture = _builder.Build(Guid.Empty, "Reindex Library", "Rebuilds the search index.", typeof(ParameterisedGlobalAction));
+ Assert.NotNull(fixture);
+ File.WriteAllText(Path.Combine(outputDirectory, "ExampleAction.ui-definition.json"), Serialize(fixture.Definition));
+
+ var inTree = _builder.Build(Guid.Empty, "Download All Images", null, typeof(DownloadAllImagesAction));
+ Assert.NotNull(inTree);
+ File.WriteAllText(Path.Combine(outputDirectory, "DownloadAllImagesAction.ui-definition.json"), Serialize(inTree.Definition));
+ }
+
+ private static IEnumerable TopLevelKeys(string json)
+ => Newtonsoft.Json.Linq.JObject.Parse(json).Properties().Select(x => x.Name).Order(StringComparer.Ordinal);
+
+ private static IEnumerable Flatten(UiElement element)
+ {
+ yield return element;
+ switch (element)
+ {
+ case UiSectionContainerElement container:
+ foreach (var item in container.Items.Values.SelectMany(Flatten))
+ yield return item;
+ break;
+ case UiListElement list:
+ foreach (var item in Flatten(list.Item))
+ yield return item;
+ break;
+ case UiRecordElement record:
+ foreach (var item in Flatten(record.KeyItem).Concat(Flatten(record.Item)))
+ yield return item;
+ break;
+ }
+ }
+
+ private static string Serialize(UiDefinition definition)
+ => JsonConvert.SerializeObject(definition, Formatting.Indented, new JsonSerializerSettings
+ {
+ MaxDepth = 10,
+ ContractResolver = new DefaultContractResolver { NamingStrategy = new DefaultNamingStrategy() },
+ NullValueHandling = NullValueHandling.Include,
+ });
+}
diff --git a/Shoko.Tests/Services/AllConfigurationTypesTests.cs b/Shoko.Tests/Services/AllConfigurationTypesTests.cs
new file mode 100644
index 0000000000..6917b13a58
--- /dev/null
+++ b/Shoko.Tests/Services/AllConfigurationTypesTests.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shoko.Abstractions.Config;
+using Shoko.Abstractions.UI;
+using Shoko.Abstractions.UI.Elements;
+using Shoko.Server.Services.Configuration;
+using Shoko.Server.Settings;
+using Xunit;
+
+namespace Shoko.Tests.Services;
+
+///
+/// Generates a schema and a UI definition for every configuration the server
+/// ships, so a change to the generator cannot break one of them at startup.
+///
+///
+/// The unit tests above drive `ServerSettings` and a handful of fixtures; the
+/// provider and renamer configurations only ever get exercised here.
+///
+[Collection(ConfigurationSchemaCollection.Name)]
+public class AllConfigurationTypesTests
+{
+ public static TheoryData ConfigurationTypes
+ {
+ get
+ {
+ var data = new TheoryData();
+ foreach (var type in typeof(ServerSettings).Assembly.GetTypes()
+ .Where(x => x is { IsClass: true, IsAbstract: false, IsGenericTypeDefinition: false } && x.IsAssignableTo(typeof(IConfiguration)))
+ .Where(x => x.GetConstructor(Type.EmptyTypes) is not null)
+ .OrderBy(x => x.FullName, StringComparer.Ordinal))
+ data.Add(type);
+ return data;
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(ConfigurationTypes))]
+ public void EveryConfiguration_ProducesASchemaAndADefinition(Type type)
+ {
+ var wrapped = ShokoJsonSchemaGeneratorGoldenTests.CreateGenerator().GetSchemaForType(type);
+ var definition = new UiDefinitionBuilder(NullLogger.Instance)
+ .Build(Guid.Empty, wrapped.Schema.Title ?? type.Name, null, wrapped);
+
+ Assert.NotNull(definition.Root);
+ Assert.All(Flatten(definition.Root), x => Assert.NotEqual(UiElementKind.Unknown, x.Kind));
+ }
+
+ private static IEnumerable Flatten(UiElement element)
+ {
+ yield return element;
+ switch (element)
+ {
+ case UiSectionContainerElement container:
+ foreach (var item in container.Items.Values.SelectMany(Flatten))
+ yield return item;
+ break;
+ case UiListElement list:
+ foreach (var item in Flatten(list.Item))
+ yield return item;
+ break;
+ case UiRecordElement record:
+ foreach (var item in Flatten(record.KeyItem).Concat(Flatten(record.Item)))
+ yield return item;
+ break;
+ }
+ }
+}
diff --git a/Shoko.Tests/Services/ConfigurationInfoUiDefinitionTests.cs b/Shoko.Tests/Services/ConfigurationInfoUiDefinitionTests.cs
new file mode 100644
index 0000000000..e82c143545
--- /dev/null
+++ b/Shoko.Tests/Services/ConfigurationInfoUiDefinitionTests.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Linq;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Namotion.Reflection;
+using NJsonSchema;
+using Shoko.Abstractions.Config;
+using Shoko.Abstractions.Config.Services;
+using Shoko.Abstractions.Plugin;
+using Shoko.Abstractions.UI;
+using Shoko.Abstractions.UI.Elements;
+using Shoko.Server.Services.Configuration;
+using Shoko.Server.Settings;
+using Xunit;
+
+namespace Shoko.Tests.Services;
+
+///
+/// Coverage for and
+/// for , the property that
+/// replaced the old GetUiDefinition(ConfigurationInfo) accessor so a
+/// configuration and an executable action are both described by a property on
+/// their info object.
+///
+[Collection(ConfigurationSchemaCollection.Name)]
+public class ConfigurationInfoUiDefinitionTests
+{
+ private static ConfigurationService CreateService()
+ {
+ var applicationPaths = new Mock();
+ applicationPaths.SetupGet(x => x.DataPath).Returns(Path.GetTempPath());
+ applicationPaths.SetupGet(x => x.ConfigurationsPath).Returns(Path.GetTempPath());
+ return new ConfigurationService(NullLoggerFactory.Instance, applicationPaths.Object, Mock.Of());
+ }
+
+ private static ConfigurationInfo CreateInfo(IConfigurationService service, Type type)
+ => new(service)
+ {
+ ID = Guid.NewGuid(),
+ Path = null,
+ Name = "Fixture",
+ Description = string.Empty,
+ HasCustomActions = false,
+ HasCustomNewFactory = false,
+ HasCustomValidation = false,
+ HasCustomSave = false,
+ HasCustomLoad = false,
+ HasLiveEdit = false,
+ Type = type,
+ ContextualType = type.ToContextualType(),
+ Schema = new JsonSchema(),
+ PluginInfo = null!,
+ };
+
+ [Fact]
+ public void TheDefinition_IsNotBuiltUntilItIsRead()
+ {
+ var service = new Mock();
+ service.Setup(x => x.GenerateUiDefinition(It.IsAny())).Returns(new UiDefinition());
+ var info = CreateInfo(service.Object, typeof(ServerSettings));
+
+ // Constructing the info must not materialise the tree; `ServerSettings`
+ // alone runs to ~120 KB and every `GetConfigurationInfo` call would pay
+ // for it.
+ service.Verify(x => x.GenerateUiDefinition(It.IsAny()), Times.Never);
+
+ _ = info.UiDefinition;
+
+ service.Verify(x => x.GenerateUiDefinition(typeof(ServerSettings)), Times.Once);
+ }
+
+ [Fact]
+ public void TheDefinition_IsBuiltOnceAndHeld()
+ {
+ var service = new Mock();
+ service.Setup(x => x.GenerateUiDefinition(It.IsAny())).Returns(() => new UiDefinition());
+ var info = CreateInfo(service.Object, typeof(ServerSettings));
+
+ var first = info.UiDefinition;
+ var second = info.UiDefinition;
+
+ // The cache lives on the info, not in the service, because the
+ // generator is type-general and has no configuration identity to key on.
+ Assert.Same(first, second);
+ service.Verify(x => x.GenerateUiDefinition(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public void TheGenerator_AcceptsATypeThatIsNotAConfiguration()
+ {
+ // The whole point of mirroring `GenerateSchema(Type)`: a plugin can
+ // describe a parameter POCO or a form model of its own, not only a
+ // registered configuration.
+ Assert.False(typeof(PlainFormModel).IsAssignableTo(typeof(IConfiguration)));
+
+ var definition = CreateService().GenerateUiDefinition(typeof(PlainFormModel));
+
+ var root = Assert.IsType(definition.Root);
+ Assert.Equal(["Query", "Limit"], root.Items.Keys);
+ Assert.IsType(root.Items["Query"]);
+ Assert.IsType(root.Items["Limit"]);
+ Assert.Equal("Plain Form Model", definition.Name);
+ Assert.Equal("A shape a plugin might want a form for.", definition.Description);
+ }
+
+ [Fact]
+ public void TheGenerator_FallsBackToAnEmptyIdForATypeOutsideAnyPlugin()
+ {
+ // Same behaviour `GenerateSchema` already has for `Schema.Id`: an id is
+ // derived from the owning plugin, and a type with no owning plugin has
+ // none to derive from. Reported rather than thrown, so the plugin case
+ // this method exists for still works.
+ Assert.Equal(Guid.Empty, CreateService().GenerateUiDefinition(typeof(PlainFormModel)).ID);
+ }
+
+ [Fact]
+ public void ARegisteredConfiguration_IsNamedTheWayItsInfoIs()
+ {
+ var service = CreateService();
+ var definition = service.GenerateUiDefinition(typeof(ServerSettings));
+
+ // `AddParts` sets `ConfigurationInfo.Name` from the schema title, so the
+ // type-general generator has to land on the same string or a
+ // configuration would be labelled differently depending on how it was
+ // reached.
+ Assert.Equal(service.GenerateSchema(typeof(ServerSettings)).Title, definition.Name);
+ }
+
+ [Fact]
+ public void TheDefinitionReachedThroughTheInfo_MatchesTheGeneratorsOutput()
+ {
+ var service = CreateService();
+ var info = CreateInfo(service, typeof(ServerSettings));
+
+ var throughInfo = info.UiDefinition;
+ var direct = service.GenerateUiDefinition(typeof(ServerSettings));
+
+ Assert.Equal(direct.ID, throughInfo.ID);
+ Assert.Equal(direct.Name, throughInfo.Name);
+ Assert.Equal(Flatten(direct.Root).Count(), Flatten(throughInfo.Root).Count());
+ }
+
+ private static IEnumerable Flatten(UiElement element)
+ {
+ yield return element;
+ switch (element)
+ {
+ case UiSectionContainerElement container:
+ foreach (var item in container.Items.Values.SelectMany(Flatten))
+ yield return item;
+ break;
+ case UiListElement list:
+ foreach (var item in Flatten(list.Item))
+ yield return item;
+ break;
+ case UiRecordElement record:
+ foreach (var item in Flatten(record.KeyItem).Concat(Flatten(record.Item)))
+ yield return item;
+ break;
+ }
+ }
+
+ ///
+ /// A shape a plugin might want a form for. The description is on the
+ /// attribute rather than the doc comment because the test assembly emits
+ /// no XML documentation file for the reflection reader to find.
+ ///
+ [Display(Description = "A shape a plugin might want a form for.")]
+ public class PlainFormModel
+ {
+ /// The text to match against.
+ public string Query { get; set; } = string.Empty;
+
+ /// How many entries to touch at most.
+ public int Limit { get; set; } = 25;
+ }
+}
diff --git a/Shoko.Tests/Services/NestedCollectionConfigurations.cs b/Shoko.Tests/Services/NestedCollectionConfigurations.cs
new file mode 100644
index 0000000000..e0358c1eb9
--- /dev/null
+++ b/Shoko.Tests/Services/NestedCollectionConfigurations.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using Shoko.Abstractions.Config;
+
+namespace Shoko.Tests.Services;
+
+/// A list of lists.
+public class NestedListOfListConfiguration : INewtonsoftJsonConfiguration
+{
+ /// The offending property.
+ public List> Values { get; set; } = [];
+}
+
+/// A list of dictionaries.
+public class NestedListOfRecordConfiguration : INewtonsoftJsonConfiguration
+{
+ /// The offending property.
+ public List> Values { get; set; } = [];
+}
+
+/// A dictionary of lists.
+public class NestedRecordOfListConfiguration : INewtonsoftJsonConfiguration
+{
+ /// A legitimate shape: the two levels get distinct keys.
+ public Dictionary> Values { get; set; } = [];
+}
+
+/// A dictionary of dictionaries.
+public class NestedRecordOfRecordConfiguration : INewtonsoftJsonConfiguration
+{
+ /// The offending property.
+ public Dictionary> Values { get; set; } = [];
+}
+
+/// An array of arrays, for the non-generic path.
+public class NestedArrayOfArrayConfiguration : INewtonsoftJsonConfiguration
+{
+ /// The offending property.
+ public string[][] Values { get; set; } = [];
+}
+
+/// The supported way to write the same thing.
+public class WrappedNestedCollectionConfiguration : INewtonsoftJsonConfiguration
+{
+ /// The inner collection, wrapped in a class.
+ public List Rows { get; set; } = [];
+}
+
+/// A row holding the inner collection.
+public class NestedCollectionRow
+{
+ /// The inner collection.
+ public List Values { get; set; } = [];
+}
diff --git a/Shoko.Tests/Services/ShokoJsonSchemaGeneratorGoldenTests.cs b/Shoko.Tests/Services/ShokoJsonSchemaGeneratorGoldenTests.cs
new file mode 100644
index 0000000000..2060178142
--- /dev/null
+++ b/Shoko.Tests/Services/ShokoJsonSchemaGeneratorGoldenTests.cs
@@ -0,0 +1,200 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shoko.Abstractions.UI;
+using Shoko.Abstractions.UI.Elements;
+using Shoko.Server.Services.Configuration;
+using Shoko.Server.Settings;
+using Xunit;
+
+namespace Shoko.Tests.Services;
+
+///
+/// Pins the serialised schema — x-uiDefinition and all — against a
+/// committed golden file.
+///
+///
+///
+/// reads the
+/// x-uiDefinition bag back for environment-variable splicing,
+/// env-lock enforcement, enum alias resolution and restart-pending
+/// detection, so its shape is load-bearing and may not drift. The bag
+/// carries nothing beyond those four jobs — presentation lives in
+/// — so any entry appearing here that the
+/// validator does not read is a regression.
+///
+///
+/// Set SHOKO_UPDATE_GOLDEN=1 to rewrite the goldens after an
+/// intentional change.
+///
+///
+[Collection(ConfigurationSchemaCollection.Name)]
+public class ShokoJsonSchemaGeneratorGoldenTests
+{
+ internal static ShokoJsonSchemaGenerator CreateGenerator()
+ => new(ShokoJsonSerializers.CreateNewtonsoftSettings(), ShokoJsonSerializers.CreateSystemTextJsonOptions());
+
+ [Theory]
+ [InlineData(typeof(ServerSettings), "ServerSettings")]
+ [InlineData(typeof(NewtonsoftTwinConfiguration), "NewtonsoftTwin")]
+ [InlineData(typeof(SystemTextJsonTwinConfiguration), "SystemTextJsonTwin")]
+ [InlineData(typeof(InheritingConfiguration), "Inheriting")]
+ public void Schema_MatchesTheGolden(Type type, string goldenName)
+ {
+ var actual = CreateGenerator().GetSchemaForType(type).Schema.ToJson().ReplaceLineEndings("\n");
+ var goldenPath = Path.Combine(TestPaths.DataDirectory, "Configuration", $"{goldenName}.schema.golden.json");
+ if (Environment.GetEnvironmentVariable("SHOKO_UPDATE_GOLDEN") is "1")
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(goldenPath)!);
+ File.WriteAllText(goldenPath, actual);
+ return;
+ }
+
+ Assert.True(File.Exists(goldenPath), $"Missing golden file '{goldenPath}'. Run the suite with SHOKO_UPDATE_GOLDEN=1 to create it.");
+ Assert.Equal(File.ReadAllText(goldenPath).ReplaceLineEndings("\n"), actual);
+ }
+}
+
+///
+/// A collection cannot hold another collection: every schema node a property
+/// produces is filed under one +List and one +Dict marker at
+/// most, so the two levels are indistinguishable.
+///
+[Collection(ConfigurationSchemaCollection.Name)]
+public class NestedCollectionTests
+{
+ [Theory]
+ [InlineData(typeof(NestedListOfListConfiguration), "List>")]
+ [InlineData(typeof(NestedListOfRecordConfiguration), "List>")]
+ [InlineData(typeof(NestedRecordOfRecordConfiguration), "Dictionary>")]
+ [InlineData(typeof(NestedArrayOfArrayConfiguration), "String[][]")]
+ public void NestedCollection_IsRejectedWithAnActionableError(Type type, string expectedTypeName)
+ {
+ var generator = ShokoJsonSchemaGeneratorGoldenTests.CreateGenerator();
+ var exception = Assert.Throws(() => generator.GetSchemaForType(type));
+
+ Assert.Contains($"\"{type.Name}.Values\"", exception.Message, StringComparison.Ordinal);
+ Assert.Contains(expectedTypeName, exception.Message, StringComparison.Ordinal);
+ Assert.Contains("Wrap the inner collection in a class", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ADictionaryOfCollections_IsAccepted()
+ {
+ // The two levels get distinct keys ("+Dict" and "+List"), so nothing
+ // collides and the generator produces a usable schema. A dictionary of
+ // scalar arrays is an ordinary shape and must keep working.
+ AssertDescribes(typeof(NestedRecordOfListConfiguration), "Values");
+ }
+
+ [Fact]
+ public void WrappingTheInnerCollectionInAClass_IsAccepted()
+ {
+ AssertDescribes(typeof(WrappedNestedCollectionConfiguration), "Rows");
+ }
+
+ [Fact]
+ public void AStringIsNotACollectionOfCharacters()
+ {
+ // `string` implements `IEnumerable`, so a naive check would
+ // reject every `List` in the tree.
+ AssertDescribes(typeof(NestedCollectionRow), "Values");
+ }
+
+ ///
+ /// Asserts the generator got far enough through
+ /// to describe as a real element.
+ ///
+ ///
+ /// The schema's x-uiDefinition bag is no longer a proxy for this:
+ /// it only carries what the validator reads, so a property with no
+ /// environment variable, no restart flag and no enum gets no bag at all.
+ /// The UI definition is where a described property now shows up.
+ ///
+ /// The configuration type to generate.
+ /// The property key to look for.
+ private static void AssertDescribes(Type type, string key)
+ {
+ var wrapped = ShokoJsonSchemaGeneratorGoldenTests.CreateGenerator().GetSchemaForType(type);
+ var definition = new UiDefinitionBuilder(NullLogger.Instance).Build(Guid.Empty, type.Name, null, wrapped);
+ var element = Flatten(definition.Root)
+ .OfType()
+ .Select(container => container.Items.GetValueOrDefault(key))
+ .FirstOrDefault(x => x is not null);
+
+ Assert.NotNull(element);
+ Assert.IsNotType(element);
+ }
+
+ private static IEnumerable Flatten(UiElement element)
+ {
+ yield return element;
+ switch (element)
+ {
+ case UiSectionContainerElement container:
+ foreach (var item in container.Items.Values.SelectMany(Flatten))
+ yield return item;
+ break;
+ case UiListElement list:
+ foreach (var item in Flatten(list.Item))
+ yield return item;
+ break;
+ case UiRecordElement record:
+ foreach (var item in Flatten(record.KeyItem).Concat(Flatten(record.Item)))
+ yield return item;
+ break;
+ }
+ }
+}
+
+///
+/// Groups every test that drives into
+/// one xUnit collection.
+///
+///
+/// Generating two schemas at once races inside NJsonSchema's and
+/// Namotion.Reflection's shared XML-documentation caches and intermittently
+/// drops descriptions. The server generates schemas serially under a lock, so
+/// this only ever bites the test host.
+///
+[CollectionDefinition(Name, DisableParallelization = true)]
+public class ConfigurationSchemaCollection
+{
+ /// The collection's name.
+ public const string Name = "ConfigurationSchema";
+}
+
+///
+/// Locates the repository directories the tests read from and write to.
+///
+internal static class TestPaths
+{
+ ///
+ /// The repository root, found by walking up from the test assembly.
+ ///
+ public static string RepositoryRoot { get; } = FindRepositoryRoot();
+
+ ///
+ /// Where the committed golden files live.
+ ///
+ public static string DataDirectory { get; } = Path.Combine(RepositoryRoot, "Shoko.Tests", "Data");
+
+ ///
+ /// Where the proof-of-concept dumps are written.
+ ///
+ public static string OutputDirectory { get; } = Path.Combine(RepositoryRoot, "poc-output");
+
+ private static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Shoko.Server.sln")))
+ directory = directory.Parent;
+ return directory?.FullName ?? AppContext.BaseDirectory;
+ }
+}
diff --git a/Shoko.Tests/Services/UiActionFixtures.cs b/Shoko.Tests/Services/UiActionFixtures.cs
new file mode 100644
index 0000000000..ac7332d8dd
--- /dev/null
+++ b/Shoko.Tests/Services/UiActionFixtures.cs
@@ -0,0 +1,246 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using System.Threading;
+using System.Threading.Tasks;
+using Shoko.Abstractions.Actions;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Tests.Services;
+
+///
+/// A global action with a realistic parameter set.
+///
+///
+/// No in-tree action declares parameters today, so the fixtures in this file
+/// are the only coverage the parameter description has. Every member of the
+/// metadata surface is declared explicitly rather than left to the interface
+/// defaults, so the exclusion rule is exercised against real properties on
+/// the concrete type.
+///
+[Section(DisplaySectionType.FieldSet, DefaultSectionName = "General")]
+public class ParameterisedGlobalAction : IExecutableAction
+{
+ ///
+ public string Name => "Reindex Library";
+
+ ///
+ public string? Description => "Rebuilds the search index.";
+
+ ///
+ public ActionCategory Category => ActionCategory.Maintenance;
+
+ ///
+ public ActionPermission Permission => ActionPermission.Admin;
+
+ ///
+ public bool RequiresConfirmation => true;
+
+ /// The text to match against.
+ [Display(Name = "Search Query", Order = 1)]
+ [Badge("New", Theme = DisplayColorTheme.Primary)]
+ [DefaultValue("")]
+ public string Query { get; set; } = string.Empty;
+
+ /// How thorough to be.
+ [Display(Order = 2)]
+ [SectionName("Behaviour")]
+ public TwinMode Mode { get; set; } = TwinMode.Balanced;
+
+ /// How many entries to touch at most.
+ [Display(Order = 3)]
+ [Range(1, 100)]
+ [Visibility(Size = DisplayElementSize.Small, Advanced = true, ToggleWhenMemberIsSet = nameof(DryRun), ToggleWhenSetTo = true, ToggleVisibilityTo = DisplayVisibility.ReadOnly)]
+ public int MaxResults { get; set; } = 25;
+
+ /// The tags to restrict the run to.
+ [Display(Order = 4)]
+ [List(UniqueItems = true)]
+ public List Tags { get; set; } = [];
+
+ /// Whether to report instead of write.
+ [Display(Order = 5)]
+ public bool DryRun { get; set; }
+
+ ///
+ public Task Execute(CancellationToken token = default)
+ => Task.CompletedTask;
+}
+
+///
+/// A global action that takes no parameters at all — the common case.
+///
+public class ParameterlessGlobalAction : IExecutableAction
+{
+ ///
+ public string Name => "Run Import";
+
+ ///
+ public string? Description => "Sweeps every managed folder.";
+
+ ///
+ public ActionCategory Category => ActionCategory.Maintenance;
+
+ ///
+ public ActionPermission Permission => ActionPermission.Admin;
+
+ ///
+ public bool RequiresConfirmation => false;
+
+ ///
+ public Task Execute(CancellationToken token = default)
+ => Task.CompletedTask;
+}
+
+///
+/// An action whose parameters cannot be described: a collection inside a
+/// collection has no renderable form and no distinct schema key.
+///
+public class UndescribableGlobalAction : IExecutableAction
+{
+ /// The offending parameter.
+ public List> Nested { get; set; } = [];
+
+ ///
+ public string Name => "Undescribable";
+
+ ///
+ public string? Description => null;
+
+ ///
+ public ActionCategory Category => ActionCategory.Maintenance;
+
+ ///
+ public ActionPermission Permission => ActionPermission.Admin;
+
+ ///
+ public bool RequiresConfirmation => false;
+
+ ///
+ public Task Execute(CancellationToken token = default)
+ => Task.CompletedTask;
+}
+
+///
+/// A scoped action, which adds Scope and a protected entity context on
+/// top of what declares.
+///
+public class ParameterisedSeriesAction : SeriesAction
+{
+ ///
+ public override string Name => "Rescan Series";
+
+ ///
+ public override ActionPermission Permission => ActionPermission.User;
+
+ /// Whether to go past the cache.
+ [Display(Order = 1)]
+ public bool Force { get; set; }
+
+ ///
+ /// Reads the context the framework populates, so the property cannot be
+ /// optimised away and genuinely exists on the walked type.
+ ///
+ public override Task Execute(CancellationToken token = default)
+ => Task.FromResult(Series);
+}
+
+///
+/// An action whose parameter merely shares a name with a metadata member.
+///
+///
+/// is implemented explicitly, which
+/// frees the public Name to be an ordinary parameter of a different
+/// type. It is here to pin that the exclusion rule matches on name
+/// and type rather than on name alone.
+///
+public class ShadowedNameAction : IExecutableAction
+{
+ string IExecutableAction.Name => "Shadowed";
+
+ ///
+ public ActionPermission Permission => ActionPermission.Admin;
+
+ /// An ordinary parameter that happens to be called Name.
+ public int Name { get; set; }
+
+ ///
+ public Task Execute(CancellationToken token = default)
+ => Task.CompletedTask;
+}
+
+///
+/// An action that declares its metadata as ordinary settable properties.
+///
+///
+/// Nothing stops a plugin author writing an action this way — the interface
+/// only asks for a getter. It exists to prove that the metadata surface is
+/// hidden from population as well as from the schema, rather than merely
+/// being unwritable because the in-tree actions all happen to use
+/// expression-bodied getters.
+///
+public class SettableMetadataAction : IExecutableAction
+{
+ ///
+ public string Name { get; set; } = "Settable Metadata";
+
+ ///
+ public string? Description { get; set; } = "Not a parameter.";
+
+ ///
+ public ActionCategory Category { get; set; } = ActionCategory.Maintenance;
+
+ ///
+ public ActionPermission Permission { get; set; } = ActionPermission.Admin;
+
+ ///
+ public bool RequiresConfirmation { get; set; } = true;
+
+ /// The one genuine parameter.
+ public bool Force { get; set; }
+
+ ///
+ public Task Execute(CancellationToken token = default)
+ => Task.CompletedTask;
+}
+
+///
+/// An action whose parameters include a nested class and a dictionary.
+///
+///
+/// Closing an object against unknown properties has to reach the nested
+/// class too — a typo one level down is just as silent — while leaving a
+/// dictionary alone, since a dictionary carries its value type in
+/// additionalProperties and closing it would reject every entry.
+///
+public class NestedParameterAction : IExecutableAction
+{
+ ///
+ public string Name => "Nested Parameters";
+
+ ///
+ public ActionPermission Permission => ActionPermission.Admin;
+
+ /// A nested parameter object.
+ public NestedParameterOptions Options { get; set; } = new();
+
+ /// Per-name overrides.
+ public Dictionary Weights { get; set; } = [];
+
+ ///
+ public Task Execute(CancellationToken token = default)
+ => Task.CompletedTask;
+}
+
+///
+/// The nested half of .
+///
+public class NestedParameterOptions
+{
+ /// Whether to go past the cache.
+ public bool Force { get; set; }
+
+ /// How deep to go.
+ public int Depth { get; set; } = 1;
+}
diff --git a/Shoko.Tests/Services/UiDefinitionBuilderTests.cs b/Shoko.Tests/Services/UiDefinitionBuilderTests.cs
new file mode 100644
index 0000000000..d9bd453944
--- /dev/null
+++ b/Shoko.Tests/Services/UiDefinitionBuilderTests.cs
@@ -0,0 +1,329 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Extensions.Logging.Abstractions;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Newtonsoft.Json.Serialization;
+using Shoko.Abstractions.UI;
+using Shoko.Abstractions.UI.Elements;
+using Shoko.Abstractions.UI.Enums;
+using Shoko.Server.Services.Configuration;
+using Shoko.Server.Settings;
+using Xunit;
+
+namespace Shoko.Tests.Services;
+
+///
+/// Coverage for , the joiner that zips a
+/// finished schema with the typed builders that produced it. The dump test
+/// also writes the produced documents to poc-output/ so the shape can
+/// be eyeballed and the payload sizes compared.
+///
+[Collection(ConfigurationSchemaCollection.Name)]
+public class UiDefinitionBuilderTests
+{
+ private static UiDefinition BuildFor(Type type, string name)
+ {
+ var wrapped = ShokoJsonSchemaGeneratorGoldenTests.CreateGenerator().GetSchemaForType(type);
+ var builder = new UiDefinitionBuilder(NullLogger.Instance);
+ return builder.Build(Guid.Empty, name, null, wrapped);
+ }
+
+ private static (UiDefinition Definition, string SchemaJson) BuildForServerSettings()
+ {
+ var wrapped = ShokoJsonSchemaGeneratorGoldenTests.CreateGenerator().GetSchemaForType(typeof(ServerSettings));
+ var builder = new UiDefinitionBuilder(NullLogger.Instance);
+ var definition = builder.Build(Guid.Empty, wrapped.Schema.Title ?? "Core Settings", null, wrapped);
+ return (definition, wrapped.Schema.ToJson());
+ }
+
+ ///
+ /// Every element reachable by key, paired with the key its container
+ /// files it under. A list's item and a record's key and value elements
+ /// are not filed under a key, so they do not appear here.
+ ///
+ private static IEnumerable<(string Key, UiElement Element)> Keyed(UiElement root)
+ => Flatten(root)
+ .OfType()
+ .SelectMany(container => container.Items.Select(entry => (entry.Key, entry.Value)));
+
+ private static T Find(UiElement root, string key) where T : UiElement
+ => Assert.IsType(Assert.Single(Keyed(root), x => x.Key == key).Element);
+
+ private static IEnumerable Flatten(UiElement element)
+ {
+ yield return element;
+ switch (element)
+ {
+ case UiSectionContainerElement container:
+ foreach (var item in container.Items.Values.SelectMany(Flatten))
+ yield return item;
+ break;
+ case UiListElement list:
+ foreach (var item in Flatten(list.Item))
+ yield return item;
+ break;
+ case UiRecordElement record:
+ foreach (var item in Flatten(record.KeyItem).Concat(Flatten(record.Item)))
+ yield return item;
+ break;
+ }
+ }
+
+ [Fact]
+ public void ServerSettings_ProducesATreeWithoutUnknownOrAutoElements()
+ {
+ var (definition, _) = BuildForServerSettings();
+ var elements = Flatten(definition.Root).ToList();
+
+ Assert.IsType(definition.Root);
+ Assert.DoesNotContain(elements, x => x is UiUnknownElement);
+ Assert.All(elements, x => Assert.NotEqual(UiElementKind.Unknown, x.Kind));
+ // A client indexes into `Items` by the key it sees in `Structure`, so
+ // the map's key and the element's own key have to agree.
+ Assert.NotEmpty(Keyed(definition.Root));
+ Assert.All(
+ elements.OfType(),
+ container => Assert.All(container.Items, entry => Assert.Equal(entry.Key, entry.Value.Key))
+ );
+ }
+
+ [Fact]
+ public void ServerSettings_CarriesLabelsAndConstraints()
+ {
+ var (definition, _) = BuildForServerSettings();
+ var elements = Flatten(definition.Root).ToList();
+
+ // The code editor on `ServerSettings.WebUI_Settings` has to survive as a
+ // concrete element, not as a generic string with a hint attached.
+ Assert.Contains(elements, x => x is UiCodeEditorElement);
+ // `PluginSettings.EnabledPlugins` is a `Dictionary`.
+ var record = Find(definition.Root, "EnabledPlugins");
+ Assert.IsType(record.Item);
+ Assert.IsType(record.KeyItem);
+ // Every leaf has a non-empty label; the client should never fall back to
+ // the property name itself.
+ Assert.All(Keyed(definition.Root), x => Assert.NotEqual(string.Empty, x.Element.Label));
+ }
+
+ [Fact]
+ public void EnumKeyedRecord_TypesTheKeyElementFromTheKeyType()
+ {
+ var definition = BuildFor(typeof(NewtonsoftTwinConfiguration), "Twin");
+ var elements = Flatten(definition.Root).ToList();
+
+ // `Dictionary` — the schema says nothing about the key,
+ // so this can only come off the builder's `KeyType`.
+ var weights = Find(definition.Root, "Weights");
+ var key = Assert.IsType(weights.KeyItem);
+ Assert.Equal(["slow-and-steady", "balanced", "fast"], key.Values.Select(x => x.Value));
+ Assert.Equal(["Slow", "Balanced", "Very Fast"], key.Values.Select(x => x.Title));
+ Assert.IsType(weights.Item);
+
+ // `Dictionary` still gets a free-text key.
+ var toggles = Find(definition.Root, "Toggles");
+ Assert.IsType(toggles.KeyItem);
+ Assert.IsType(toggles.Item);
+ }
+
+ [Fact]
+ public void SectionContainer_InterleavesChildrenAndActionsInTheAuthoredOrder()
+ {
+ var definition = BuildFor(typeof(NewtonsoftTwinConfiguration), "Twin");
+ var body = Find(definition.Root, "Body");
+
+ // The structure lists every item and every action exactly once, in the
+ // authored order, with the actions after the last property.
+ Assert.Equal(
+ body.Items.Keys.Concat(body.Actions.Keys),
+ body.Structure.Select(x => x.Name)
+ );
+ Assert.Equal(body.Items.Count, body.Structure.Count(x => x.Kind is UiStructureMemberKind.Item));
+ Assert.Equal(body.Actions.Count, body.Structure.Count(x => x.Kind is UiStructureMemberKind.Action));
+ Assert.Equal(["Name", "Enabled", "Count", "Ratio", "Mode"], body.Items.Keys.Take(5));
+ Assert.Equal(["DoTheThingAction", "DoAnotherThingAction"], body.Actions.Keys);
+ }
+
+ [Fact]
+ public void InheritedMembers_KeepTheirDefinition()
+ {
+ var definition = BuildFor(typeof(InheritingConfiguration), "Inheriting");
+ var root = Assert.IsType(definition.Root);
+
+ // Every one of these but `Count` is declared on the base class, and the
+ // generator files an inherited property under the type that declares
+ // it, not under the one being generated.
+ Assert.Equal(["Name", "Mode", "Endpoints", "Count"], root.Items.Keys);
+ Assert.Equal(["Inherited Name", "Mode", "Endpoints", "Derived Count"], root.Items.Values.Select(x => x.Label));
+ Assert.Equal(["DoTheInheritedThingAction"], root.Actions.Keys);
+
+ var name = root.Items["Name"];
+ Assert.True(name.RequiresRestart);
+ Assert.Equal("INHERITED_NAME", name.EnvironmentVariable?.Name);
+ Assert.Equal("Inherited", root.Items["Mode"].SectionName);
+ // The derived class's own section attribute wins over the base's.
+ Assert.Equal(DisplaySectionType.Tab, root.SectionType);
+ Assert.Equal("Derived", root.DefaultSectionName);
+ Assert.True(root.ShowSaveAction);
+ // An inherited list still resolves its item class and primary key.
+ var endpoints = Assert.IsType(root.Items["Endpoints"]);
+ Assert.Equal(DisplayListType.ComplexTab, endpoints.ListType);
+ Assert.Equal("ID", Assert.IsType(endpoints.Item).PrimaryKey);
+ }
+
+ [Fact]
+ public void BothSerializerPaths_ProduceTheSameDefinition()
+ {
+ var newtonsoft = Serialize(BuildFor(typeof(NewtonsoftTwinConfiguration), "Twin"))
+ .Replace("Newtonsoft Twin", "Twin", StringComparison.Ordinal);
+ var systemTextJson = Serialize(BuildFor(typeof(SystemTextJsonTwinConfiguration), "Twin"))
+ .Replace("System Text Json Twin", "Twin", StringComparison.Ordinal);
+
+ // The only authored difference between the two twins is which
+ // serializer interface they implement. `[EnumMember]` (Newtonsoft) and
+ // `[JsonStringEnumMemberName]` (System.Text.Json) have to agree for
+ // this to hold.
+ //
+ // `DeniedValues` is dropped before comparing: literal values are
+ // rendered by the configuration's own serializer, and the two disagree
+ // on how to write a whole-numbered double. See
+ // `BothSerializerPaths_DisagreeOnWholeNumberedDoubleLiterals`.
+ Assert.Equal(WithoutDeniedValues(newtonsoft), WithoutDeniedValues(systemTextJson));
+ }
+
+ [Fact]
+ public void BothSerializerPaths_DisagreeOnWholeNumberedDoubleLiterals()
+ {
+ var newtonsoft = Find(BuildFor(typeof(NewtonsoftTwinConfiguration), "Twin").Root, "Ratio");
+ var systemTextJson = Find(BuildFor(typeof(SystemTextJsonTwinConfiguration), "Twin").Root, "Ratio");
+
+ // `[DeniedValues(0.0, 1.0)]` on a `double`. The values are rendered by
+ // the configuration's own serializer so they line up with the values in
+ // the configuration document, and Newtonsoft keeps the decimal point
+ // where System.Text.Json drops it. They compare equal numerically, so
+ // this only bites a client doing a textual comparison.
+ Assert.Equal(["0.0", "1.0"], newtonsoft.DeniedValues!.Select(x => x!.ToString(Formatting.None)));
+ Assert.Equal(["0", "1"], systemTextJson.DeniedValues!.Select(x => x!.ToString(Formatting.None)));
+ }
+
+ [Fact]
+ public void ServerSettings_DumpsDefinitionAndReportsPayloadSize()
+ {
+ var (definition, schemaJson) = BuildForServerSettings();
+
+ // Mirrors the MVC pipeline, `MaxDepth` included: the produced tree is
+ // deeper than 10 levels, so this doubles as a check that the pipeline
+ // can actually emit it.
+ var mvcSettings = new JsonSerializerSettings
+ {
+ MaxDepth = 10,
+ ContractResolver = new DefaultContractResolver { NamingStrategy = new DefaultNamingStrategy() },
+ NullValueHandling = NullValueHandling.Include,
+ };
+ var leanSettings = new JsonSerializerSettings
+ {
+ ContractResolver = new DefaultContractResolver { NamingStrategy = new DefaultNamingStrategy() },
+ NullValueHandling = NullValueHandling.Ignore,
+ };
+
+ var definitionJson = JsonConvert.SerializeObject(definition, Formatting.Indented, mvcSettings);
+ var definitionMinified = JsonConvert.SerializeObject(definition, Formatting.None, mvcSettings);
+ var definitionLean = JsonConvert.SerializeObject(definition, Formatting.None, leanSettings);
+ var schemaMinified = JToken.Parse(schemaJson).ToString(Formatting.None);
+ var elements = Flatten(definition.Root).ToList();
+
+ var outputDirectory = TestPaths.OutputDirectory;
+ Directory.CreateDirectory(outputDirectory);
+ File.WriteAllText(Path.Combine(outputDirectory, "ServerSettings.ui-definition.json"), definitionJson);
+ File.WriteAllText(Path.Combine(outputDirectory, "ServerSettings.schema.json"), schemaJson);
+ File.WriteAllText(
+ Path.Combine(outputDirectory, "payload-sizes.txt"),
+ string.Join(
+ Environment.NewLine,
+ "Payload comparison for ServerSettings (bytes, UTF-8, minified unless noted)",
+ $" /Schema : {schemaMinified.Length,8}",
+ $" /UiDefinition (NullValueHandling.Include, as MVC would emit it): {definitionMinified.Length,8}",
+ $" /UiDefinition (NullValueHandling.Ignore) : {definitionLean.Length,8}",
+ $" ratio vs schema (Include) : {(double)definitionMinified.Length / schemaMinified.Length:0.00}x",
+ $" ratio vs schema (Ignore) : {(double)definitionLean.Length / schemaMinified.Length:0.00}x",
+ string.Empty,
+ "Element census",
+ $" elements : {elements.Count,8}",
+ $" with a default : {elements.Count(x => x.Default is not null),8}",
+ $" hoisted definitions : {definition.Definitions.Count,8}",
+ string.Empty
+ )
+ );
+
+ Assert.True(definitionMinified.Length > 0);
+ Assert.True(schemaMinified.Length > 0);
+ }
+
+ [Fact]
+ public void DisplayButtonPosition_SerializesToItsAuthoredNameOnBothPaths()
+ {
+ foreach (var type in new[] { typeof(NewtonsoftTwinConfiguration), typeof(SystemTextJsonTwinConfiguration) })
+ {
+ var actions = Flatten(BuildFor(type, "Twin").Root).OfType()
+ .SelectMany(x => x.Actions)
+ .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal);
+
+ // Every member of the enum now carries a distinct value, so name
+ // resolution is deterministic and reaches the attribute. While the
+ // aliases existed, a button authored as `Start`/`Top` went out as
+ // `"Left"` and one authored as `End` went out as `"Right"`, on both
+ // serializer paths.
+ Assert.Equal(DisplayButtonPosition.Start, actions["DoTheThingAction"].Position);
+ Assert.Equal(DisplayButtonPosition.End, actions["DoAnotherThingAction"].Position);
+ Assert.Equal("\"start\"", JsonConvert.SerializeObject(DisplayButtonPosition.Start));
+ Assert.Equal("\"end\"", JsonConvert.SerializeObject(DisplayButtonPosition.End));
+ Assert.Equal("\"auto\"", JsonConvert.SerializeObject(DisplayButtonPosition.Auto));
+ Assert.Equal("\"start\"", System.Text.Json.JsonSerializer.Serialize(DisplayButtonPosition.Start));
+ Assert.Equal("\"end\"", System.Text.Json.JsonSerializer.Serialize(DisplayButtonPosition.End));
+ Assert.Equal("\"auto\"", System.Text.Json.JsonSerializer.Serialize(DisplayButtonPosition.Auto));
+ }
+ }
+
+ [Fact]
+ public void RecursiveConfiguration_HoistsTheCycleIntoDefinitions()
+ {
+ var definition = BuildFor(typeof(RecursiveNode), "Recursive");
+
+ var elements = Flatten(definition.Root).ToList();
+ var reference = Assert.Single(elements.OfType());
+ Assert.True(definition.Definitions.ContainsKey(reference.Reference));
+ // The hoisted definition is a real container, not a self-reference.
+ Assert.IsType(definition.Definitions[reference.Reference]);
+ }
+
+ private static string WithoutDeniedValues(string json)
+ {
+ var token = JToken.Parse(json);
+ foreach (var denied in token.SelectTokens("$..DeniedValues").ToList())
+ denied.Replace(JValue.CreateNull());
+ return token.ToString(Formatting.Indented);
+ }
+
+ private static string Serialize(UiDefinition definition)
+ => JsonConvert.SerializeObject(definition, Formatting.Indented, new JsonSerializerSettings
+ {
+ MaxDepth = 10,
+ ContractResolver = new DefaultContractResolver { NamingStrategy = new DefaultNamingStrategy() },
+ NullValueHandling = NullValueHandling.Include,
+ });
+
+ ///
+ /// A deliberately self-recursive shape; nothing in-tree currently
+ /// recurses, so the cycle handling would otherwise go untested.
+ ///
+ public class RecursiveNode
+ {
+ /// The node's name.
+ public string Name { get; set; } = string.Empty;
+
+ /// The node's items.
+ public List Items { get; set; } = [];
+ }
+}
diff --git a/Shoko.Tests/Services/UiTwinConfiguration.cs b/Shoko.Tests/Services/UiTwinConfiguration.cs
new file mode 100644
index 0000000000..43860813d3
--- /dev/null
+++ b/Shoko.Tests/Services/UiTwinConfiguration.cs
@@ -0,0 +1,227 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+using Shoko.Abstractions.Config;
+using Shoko.Abstractions.Config.Attributes;
+using Shoko.Abstractions.Config.Enums;
+using Shoko.Abstractions.UI.Attributes;
+using Shoko.Abstractions.UI.Components;
+using Shoko.Abstractions.UI.Enums;
+
+namespace Shoko.Tests.Services;
+
+///
+/// The Newtonsoft-serialised twin.
+///
+///
+/// Both twins hold the same , so any difference between
+/// the two produced definitions comes from the serialiser and nothing else.
+/// The body is held rather than inherited on purpose: the generator keys its
+/// property bags on MemberInfo.ReflectedType but its class bags on the
+/// contextual type, so inherited properties silently lose their bag.
+///
+[Section(DisplaySectionType.Tab, DefaultSectionName = "General", AppendFloatingSectionsAtEnd = true, ShowSaveAction = true)]
+public class NewtonsoftTwinConfiguration : INewtonsoftJsonConfiguration
+{
+ /// The shared body.
+ public TwinBody Body { get; set; } = new();
+}
+
+///
+/// The System.Text.Json-serialised twin.
+///
+[Section(DisplaySectionType.Tab, DefaultSectionName = "General", AppendFloatingSectionsAtEnd = true, ShowSaveAction = true)]
+public class SystemTextJsonTwinConfiguration : IConfiguration
+{
+ /// The shared body.
+ public TwinBody Body { get; set; } = new();
+}
+
+///
+/// Serialiser-agnostic shape carrying one of every element the generator can
+/// emit.
+///
+[Display(Name = "Twin Body")]
+[Section(DisplaySectionType.FieldSet, DefaultSectionName = "General", AppendFloatingSectionsAtEnd = true)]
+public class TwinBody
+{
+ /// The name of the thing.
+ [Display(Name = "Display Name", Order = 1)]
+ [Badge("New", Theme = DisplayColorTheme.Primary)]
+ [DefaultValue("shoko")]
+ public string Name { get; set; } = "shoko";
+
+ /// Whether the thing is on.
+ [Display(Order = 2)]
+ [RequiresRestart]
+ [EnvironmentVariable("TWIN_ENABLED", AllowOverride = false)]
+ public bool Enabled { get; set; } = true;
+
+ /// How many things.
+ [Display(Order = 3)]
+ [Range(1, 100)]
+ [Visibility(Size = DisplayElementSize.Small, Advanced = true, ToggleWhenMemberIsSet = nameof(Enabled), ToggleWhenSetTo = false, ToggleVisibilityTo = DisplayVisibility.ReadOnly)]
+ public int Count { get; set; } = 4;
+
+ /// How much of a thing.
+ [Display(Order = 4, GroupName = "Tuning")]
+ [DeniedValues(0.0, 1.0)]
+ public double Ratio { get; set; } = 0.5;
+
+ /// The mode to run in.
+ [Display(Order = 5)]
+ [SectionName("Behaviour")]
+ public TwinMode Mode { get; set; } = TwinMode.Balanced;
+
+ /// The modes to offer.
+ [Display(Order = 6)]
+ [SectionName("Behaviour")]
+ [List(ListType = DisplayListType.EnumCheckbox)]
+ public List Modes { get; set; } = [];
+
+ /// A secret.
+ [Display(Order = 7)]
+ [PasswordPropertyText]
+ public string Secret { get; set; } = string.Empty;
+
+ /// A longer note.
+ [Display(Order = 8)]
+ [TextArea]
+ public string Note { get; set; } = string.Empty;
+
+ /// Some code.
+ [Display(Order = 9)]
+ [CodeEditor(CodeEditorLanguage.Json, AutoFormatOnLoad = true)]
+ public string Script { get; set; } = "{}";
+
+ /// The endpoints to talk to.
+ [Display(Order = 10)]
+ [List(ListType = DisplayListType.ComplexTab)]
+ public List Endpoints { get; set; } = [];
+
+ /// Per-mode weights, keyed by an enum.
+ ///
+ /// The key type here is the reason the record key element cannot be
+ /// hardcoded to a string element.
+ ///
+ [Display(Order = 11)]
+ [Record(HideRemoveAction = true)]
+ public Dictionary Weights { get; set; } = [];
+
+ /// Per-name toggles, keyed by a string.
+ [Display(Order = 12)]
+ public Dictionary Toggles { get; set; } = [];
+
+ /// A server-populated selection.
+ [Display(Order = 13)]
+ [Select(SelectType = DisplaySelectType.CheckboxList, MultipleItems = true)]
+ public SelectComponent Picked { get; set; } = new();
+
+ /// Does a thing.
+ [Display(Name = "Do The Thing")]
+ [CustomAction(Icon = "Play", Theme = DisplayColorTheme.Secondary, Position = DisplayButtonPosition.Start, Size = DisplayElementSize.Small, DisableIfNoChanges = true)]
+ public void DoTheThingAction() { }
+
+ /// Does another thing.
+ [CustomAction(Position = DisplayButtonPosition.End, SectionName = "Behaviour", ToggleWhenMemberIsSet = nameof(Enabled), ToggleWhenSetTo = true)]
+ public void DoAnotherThingAction() { }
+
+ /// Validates the configuration.
+ [ConfigurationAction(ConfigurationActionType.Validate)]
+ public void ValidateHandler() { }
+}
+
+///
+/// An endpoint entry, used as a complex list item so the list gets a primary
+/// key and a class definition of its own.
+///
+[Section(DisplaySectionType.FieldSet)]
+public class TwinEndpoint
+{
+ /// The endpoint's id.
+ [Key]
+ [Display(Order = 1)]
+ public string ID { get; set; } = string.Empty;
+
+ /// The endpoint's url.
+ [Display(Order = 2)]
+ [Url]
+ public string Url { get; set; } = string.Empty;
+
+ /// The endpoint's mode.
+ [Display(Order = 3)]
+ public TwinMode Mode { get; set; } = TwinMode.Balanced;
+}
+
+///
+/// An enum that carries both serialisers' naming attributes, in agreement.
+///
+[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum TwinMode
+{
+ /// Go slow.
+ [EnumMember(Value = "slow-and-steady")]
+ [JsonStringEnumMemberName("slow-and-steady")]
+ [Description("Takes its time.")]
+ Slow = 0,
+
+ /// Go at a sensible pace.
+ [EnumMember(Value = "balanced")]
+ [JsonStringEnumMemberName("balanced")]
+ Balanced = 1,
+
+ /// Go fast.
+ [EnumMember(Value = "fast")]
+ [JsonStringEnumMemberName("fast")]
+ [Display(Name = "Very Fast")]
+ Fast = 2,
+}
+
+///
+/// A base class whose members a configuration inherits rather than holds.
+///
+///
+/// NJsonSchema hands a schema processor an inherited property through the
+/// type that declares it, so this is the shape that used to lose its
+/// x-uiDefinition entirely.
+///
+[Section(DisplaySectionType.Minimal, DefaultSectionName = "Base")]
+public class InheritedConfigurationBase
+{
+ /// The name of the thing.
+ [Display(Name = "Inherited Name", Order = 1)]
+ [RequiresRestart]
+ [EnvironmentVariable("INHERITED_NAME")]
+ public string Name { get; set; } = string.Empty;
+
+ /// The mode to run in.
+ [Display(Order = 2)]
+ [SectionName("Inherited")]
+ public TwinMode Mode { get; set; } = TwinMode.Balanced;
+
+ /// The endpoints to talk to.
+ [Display(Order = 3)]
+ [List(ListType = DisplayListType.ComplexTab)]
+ public List Endpoints { get; set; } = [];
+
+ /// Does an inherited thing.
+ [Display(Name = "Do The Inherited Thing")]
+ [CustomAction(Icon = "Play")]
+ public void DoTheInheritedThingAction() { }
+}
+
+///
+/// A configuration that inherits most of its members.
+///
+[Display(Name = "Inheriting")]
+[Section(DisplaySectionType.Tab, DefaultSectionName = "Derived", ShowSaveAction = true)]
+public class InheritingConfiguration : InheritedConfigurationBase, INewtonsoftJsonConfiguration
+{
+ /// Something only the derived class has.
+ [Display(Name = "Derived Count", Order = 4)]
+ [Badge("New", Theme = DisplayColorTheme.Primary)]
+ public int Count { get; set; } = 1;
+}
diff --git a/Shoko.Tests/Shoko.Tests.csproj b/Shoko.Tests/Shoko.Tests.csproj
index d99865c7ce..84940b4a0d 100644
--- a/Shoko.Tests/Shoko.Tests.csproj
+++ b/Shoko.Tests/Shoko.Tests.csproj
@@ -11,6 +11,11 @@
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -26,6 +31,8 @@
+
+