From 9b76a8fbeb813b34c4881b54d329e9e837b08f95 Mon Sep 17 00:00:00 2001
From: LukasGold <41064839+LukasGold@users.noreply.github.com>
Date: Fri, 16 Oct 2026 18:28:37 +0200
Subject: [PATCH 1/4] fix: keep the stored jsonschema when store_entity
regenerates it
- pass target_schema= instead of the ignored schema= (#215)
- replace $defs/generated in DEFINITIONS_SECTION mode instead of merging
- add offline regression tests through store_entity
---
src/osw/core.py | 33 +++-
tests/test_store_entity_keeps_jsonschema.py | 182 ++++++++++++++++++++
2 files changed, 214 insertions(+), 1 deletion(-)
create mode 100644 tests/test_store_entity_keeps_jsonschema.py
diff --git a/src/osw/core.py b/src/osw/core.py
index ed6b0a8..c1d4a95 100644
--- a/src/osw/core.py
+++ b/src/osw/core.py
@@ -210,6 +210,29 @@ def read_file_if_exists(path: str) -> str:
return f.read()
+def remove_generated_schema_section(
+ schema: dict, def_key: str = "$defs", gen_def_key: str = "generated"
+) -> dict:
+ """Removes the generated section and the allOf entry that points to it.
+
+ aggregate_generated_schemas() merges into an existing generated section and
+ merge_deep() concatenates arrays, so a stale 'required' entry or 'type' would
+ survive every regeneration. Mirrors osl.util.postProcessPage in PageBot.js of
+ mediawiki-extensions-OpenSemanticLab. Modifies schema in place.
+ """
+ gen_def_pointer = f"#/{def_key}/{gen_def_key}"
+ defs = schema.get(def_key)
+ if isinstance(defs, dict):
+ defs.pop(gen_def_key, None)
+ if isinstance(schema.get("allOf"), list):
+ schema["allOf"] = [
+ entry
+ for entry in schema["allOf"]
+ if not (isinstance(entry, dict) and entry.get("$ref") == gen_def_pointer)
+ ]
+ return schema
+
+
# Reusable type definitions
class OverwriteOptions(Enum):
"""Options for overwriting properties"""
@@ -2084,9 +2107,17 @@ def store_entity_(
if characteristic_types and isinstance(entity_, characteristic_types):
mode = AggregateGeneratedSchemasParamMode.DEFINITIONS_SECTION
+ stored_schema = page.get_slot_content("jsonschema") or {}
+ # Regenerate the generated section from scratch. Skip this when
+ # the generation failed, so the stored section is kept.
+ if (
+ mode == AggregateGeneratedSchemasParamMode.DEFINITIONS_SECTION
+ and len(generated_schemas) > 0
+ ):
+ remove_generated_schema_section(stored_schema)
new_schema = aggregate_generated_schemas(
AggregateGeneratedSchemasParam(
- schema=page.get_slot_content("jsonschema"),
+ target_schema=stored_schema,
generated_schemas=generated_schemas,
mode=mode,
)
diff --git a/tests/test_store_entity_keeps_jsonschema.py b/tests/test_store_entity_keeps_jsonschema.py
new file mode 100644
index 0000000..292a857
--- /dev/null
+++ b/tests/test_store_entity_keeps_jsonschema.py
@@ -0,0 +1,182 @@
+"""Unit tests for the jsonschema slot regeneration in store_entity().
+
+Regression guard for two defects in the schema aggregation of store_entity():
+
+1. store_entity() passed the stored schema as `schema=`, a field that
+ AggregateGeneratedSchemasParam does not have. Pydantic ignored it, so the
+ aggregation always started from `{}` and every save dropped the hand-written
+ part of the jsonschema slot.
+2. In DEFINITIONS_SECTION mode the new generated schema was merged into the
+ existing `$defs/generated` instead of replacing it, so a `required` entry or a
+ `type` from an earlier save survived every regeneration.
+
+These run fully offline: store_entity() runs with offline=True, the meta category
+is served by a fake site and the overwrite policy is wrapped to put a stored
+jsonschema slot on the page, so no network is required.
+"""
+
+import json
+
+import pytest
+from opensemantic.core.v1 import CharacteristicType
+
+import osw.model.entity as model
+from osw.core import OSW
+from osw.wtsite import WtPage
+
+META_CATEGORY = "Category:OSWMetaCategory"
+GEN_POINTER = "#/$defs/generated"
+
+
+class _FakeMetaCategoryPage:
+ def __init__(self, title, template):
+ self.title = title
+ self._slots = {"schema_template": template, "jsondata": {}}
+
+ def get_slot_content(self, slot_key):
+ return self._slots[slot_key]
+
+
+class _FakeGetPageResult:
+ def __init__(self, pages):
+ self.pages = pages
+
+
+class _FakeSite:
+ def __init__(self, template):
+ self.template = template
+
+ def get_page(self, param):
+ return _FakeGetPageResult([
+ _FakeMetaCategoryPage(t, self.template) for t in param.titles
+ ])
+
+
+@pytest.fixture
+def store(monkeypatch):
+ """Runs store_entity() offline on a page whose jsonschema slot holds `stored`.
+
+ Returns the jsonschema slot that store_entity() wrote.
+ """
+ original_policy = OSW._apply_overwrite_policy
+
+ def _store(entity, template, stored):
+ def policy_with_stored_schema(param):
+ if stored is not None:
+ param.page.set_slot_content(
+ "jsonschema", json.loads(json.dumps(stored))
+ )
+ return original_policy(param)
+
+ monkeypatch.setattr(
+ OSW, "_apply_overwrite_policy", staticmethod(policy_with_stored_schema)
+ )
+ osw_obj = OSW.construct(site=_FakeSite(template))
+ result = osw_obj.store_entity(
+ OSW.StoreEntityParam(
+ entities=[entity],
+ namespace="Category",
+ meta_category_title=META_CATEGORY,
+ offline=True,
+ )
+ )
+ (page,) = result.pages.values()
+ assert isinstance(page, WtPage)
+ return page.get_slot_content("jsonschema")
+
+ return _store
+
+
+def _characteristic():
+ return CharacteristicType(
+ uuid="efad8086-4a76-47ca-b278-f5f944e5754b",
+ name="TestCharacteristic",
+ label=[{"text": "Test Characteristic"}],
+ # the default resolves a superclass from a wiki
+ subclass_of=[],
+ )
+
+
+def _category():
+ # store_entity() selects ROOT_LEVEL mode for any entity that is not a
+ # characteristic, so a plain Item stored in the Category namespace suffices
+ return model.Item(
+ uuid="0d0c1c35-4c4d-4e3a-9c9a-6f4a1b2c3d4e",
+ name="TestCategory",
+ label=[model.Label(text="Test Category")],
+ )
+
+
+def test_root_level_keeps_hand_written_schema(store):
+ stored = {"title": "hand", "properties": {"manual": {"type": "string"}}}
+ template = '{"title": "{{name}}", "properties": {"gen": {"type": "number"}}}'
+
+ schema = store(_category(), template, stored)
+
+ assert schema["properties"]["manual"] == {"type": "string"}
+ assert schema["properties"]["gen"] == {"type": "number"}
+ assert schema["title"] == "TestCategory"
+
+
+def test_definitions_section_keeps_hand_written_schema(store):
+ stored = {"title": "hand", "$defs": {"manual": {"type": "string"}}}
+ template = '{"title": "{{name}}", "type": "object"}'
+
+ schema = store(_characteristic(), template, stored)
+
+ assert schema["$defs"]["manual"] == {"type": "string"}
+ assert schema["$defs"]["generated"]["type"] == "object"
+ assert schema["allOf"] == [{"$ref": GEN_POINTER}]
+
+
+def test_definitions_section_replaces_stale_generated_section(store):
+ stored = {"title": "hand", "$defs": {"manual": {"type": "string"}}}
+ first_template = (
+ '{"title": "{{name}}", "type": "object", "required": ["type"],'
+ ' "properties": {"type": {"type": "string"}}}'
+ )
+ second_template = (
+ '{"title": "{{name}}", "properties": {"value": {"type": "number"}}}'
+ )
+
+ first = store(_characteristic(), first_template, stored)
+ assert first["$defs"]["generated"]["required"] == ["type"]
+
+ second = store(_characteristic(), second_template, first)
+
+ generated = second["$defs"]["generated"]
+ assert "required" not in generated
+ assert "type" not in generated
+ assert "type" not in generated["properties"]
+ assert generated["properties"]["value"] == {"type": "number"}
+ assert generated["$comment"].count(META_CATEGORY) == 1
+ assert second["allOf"] == [{"$ref": GEN_POINTER}]
+ assert second["$defs"]["manual"] == {"type": "string"}
+
+
+def test_failed_generation_keeps_generated_section(store):
+ stored = {
+ "title": "hand",
+ "allOf": [{"$ref": GEN_POINTER}],
+ "$defs": {"generated": {"type": "object", "required": ["type"]}},
+ }
+ # not valid JSON, so generation fails and produces no generated schema
+ template = '{"title": {{name}}'
+
+ schema = store(_characteristic(), template, stored)
+
+ assert schema == stored
+
+
+def test_root_level_second_save_does_not_duplicate(store):
+ stored = {"title": "hand", "properties": {"manual": {"type": "string"}}}
+ template = (
+ '{"title": "{{name}}", "required": ["gen"],'
+ ' "properties": {"gen": {"type": "number"}}}'
+ )
+
+ first = store(_category(), template, stored)
+ second = store(_category(), template, first)
+
+ assert second == first
+ assert second["required"] == ["gen"]
From 9f606f48fefd640cca48e6718f1a92eca118da6b Mon Sep 17 00:00:00 2001
From: LukasGold <41064839+LukasGold@users.noreply.github.com>
Date: Thu, 5 Nov 2026 14:20:24 +0100
Subject: [PATCH 2/4] fix: select category templates from the schema allOf like
the wiki (#220)
- collect templates from the meta category allOf chain, wiki order
- pick the definitions section mode by CharacteristicType in the list
- port @context reset and merge, and superseded root member removal
- warn when a meta category schema is missing
---
src/osw/core.py | 314 +++++-
.../after-form-editor-save.json | 568 ++++++++++
.../before-form-editor-save.json | 586 +++++++++++
.../meta/Category.jsondata.json | 19 +
.../meta/Category.jsonschema.json | 115 ++
.../meta/Category.schema_template.txt | 37 +
.../meta/CharacteristicType.jsondata.json | 30 +
.../meta/CharacteristicType.jsonschema.json | 993 ++++++++++++++++++
.../CharacteristicType.schema_template.txt | 143 +++
.../meta/Entity.jsondata.json | 27 +
.../meta/Entity.jsonschema.json | 728 +++++++++++++
tests/test_store_entity_category_templates.py | 204 ++++
tests/test_store_entity_keeps_jsonschema.py | 48 +-
13 files changed, 3757 insertions(+), 55 deletions(-)
create mode 100644 tests/fixtures/category_template_selection/after-form-editor-save.json
create mode 100644 tests/fixtures/category_template_selection/before-form-editor-save.json
create mode 100644 tests/fixtures/category_template_selection/meta/Category.jsondata.json
create mode 100644 tests/fixtures/category_template_selection/meta/Category.jsonschema.json
create mode 100644 tests/fixtures/category_template_selection/meta/Category.schema_template.txt
create mode 100644 tests/fixtures/category_template_selection/meta/CharacteristicType.jsondata.json
create mode 100644 tests/fixtures/category_template_selection/meta/CharacteristicType.jsonschema.json
create mode 100644 tests/fixtures/category_template_selection/meta/CharacteristicType.schema_template.txt
create mode 100644 tests/fixtures/category_template_selection/meta/Entity.jsondata.json
create mode 100644 tests/fixtures/category_template_selection/meta/Entity.jsonschema.json
create mode 100644 tests/test_store_entity_category_templates.py
diff --git a/src/osw/core.py b/src/osw/core.py
index c1d4a95..c971d5c 100644
--- a/src/osw/core.py
+++ b/src/osw/core.py
@@ -39,15 +39,6 @@
from pyld import jsonld
import osw.model.entity as model
-
-try:
- # Characteristics of the released opensemantic packages derive from this
- # class, not from the one in osw.model.entity.
- from opensemantic.core.v1 import (
- CharacteristicType as _OpensemanticCharacteristicType,
- )
-except ImportError: # pragma: no cover - optional dependency
- _OpensemanticCharacteristicType = None
from osw.defaults import params as default_params
from osw.utils.code_postprocessing import (
remove_constraints_from_forward_refs,
@@ -58,6 +49,7 @@
AggregateGeneratedSchemasParamMode,
aggregate_generated_schemas,
escape_json_strings,
+ merge_deep,
merge_generated_definitions,
)
from osw.utils.templates import (
@@ -68,6 +60,7 @@
from osw.utils.wiki import (
get_full_title,
get_namespace,
+ get_osw_id,
get_title,
is_empty,
namespace_from_full_title,
@@ -233,6 +226,227 @@ def remove_generated_schema_section(
return schema
+CATEGORY_CATEGORY_UUID = "89aafe6d-ae5a-4f29-97ff-df7736d4cab6"
+CATEGORY_ENTITY_UUID = "ce353767-c628-45bd-9d88-d6eb3009aec0"
+# the definitions section mode is selected when this template is in the list
+CHARACTERISTIC_TYPE_TITLE = "Category:OSWffe74f291d354037b318c422591c5023"
+
+
+def _title_from_schema_ref(ref: Any) -> Optional[str]:
+ """Page title of a $ref to a category schema, None for any other $ref.
+
+ Accepts '/wiki/Category:X?action=raw&slot=jsonschema' and
+ '.../Special:SlotResolver/Category/X.slot_jsonschema.json'.
+ """
+ if not isinstance(ref, str) or ref.startswith("#"):
+ return None
+ path = ref.split("?")[0]
+ match = re.search(r"/Special:SlotResolver/([^/]+)/(.+)\.slot_[a-z]+\.json$", path)
+ if match:
+ return f"{match.group(1)}:{match.group(2)}"
+ match = re.search(r"/wiki/(Category:[^/]+)$", path)
+ if match:
+ return match.group(1)
+ if re.fullmatch(r"Category:[^/]+", path):
+ return path
+ return None
+
+
+def collect_category_template_titles(
+ get_pages, meta_category_titles: List[str]
+) -> List[str]:
+ """Titles of the category templates the wiki form editor applies.
+
+ Mirrors the editor: the schema of the meta categories is bundled, the uuid of
+ every schema is collected after the schemas below it (allOf, oneOf, anyOf,
+ definitions and $defs are followed in this order, so the most generic schema
+ comes first) and the result starts with Category:Category.
+ Category:Category and Category:Entity are not collected. Follows
+ osl.util.postProcessPage (PageBot.js) with editor.jsonschema.subschemas_uuids
+ (MwJson_schema.js, _preprocess) in mediawiki-extensions-OpenSemanticLab and
+ mediawiki-extensions-MwJson. Schemas reached through properties are not
+ followed.
+
+ Parameters
+ ----------
+ get_pages
+ callable that returns the page objects for a list of titles
+ meta_category_titles
+ titles of the meta categories, e.g. ["Category:Category"]
+ """
+ templates = ["Category:Category"]
+ schema_cache: Dict[str, dict] = {}
+ visited = set()
+
+ def schema_of(title: str) -> dict:
+ if title not in schema_cache:
+ schema = {}
+ for page in get_pages([title]):
+ if page.title == title:
+ schema = page.get_slot_content("jsonschema") or {}
+ if not schema:
+ _logger.warning(
+ f"No jsonschema found for {title}. Its templates are not "
+ "applied, which can select the wrong schema generation mode."
+ )
+ schema_cache[title] = schema
+ return schema_cache[title]
+
+ def walk(schema: Any) -> None:
+ if not isinstance(schema, dict):
+ return
+ ref_title = _title_from_schema_ref(schema.get("$ref"))
+ if ref_title is not None:
+ # a bundled schema is included once, later refs point to it
+ if ref_title not in visited:
+ visited.add(ref_title)
+ walk(schema_of(ref_title))
+ return
+ for key in ("allOf", "oneOf", "anyOf"):
+ sub = schema.get(key)
+ for entry in sub if isinstance(sub, list) else [sub]:
+ walk(entry)
+ for key in ("definitions", "$defs"):
+ if isinstance(schema.get(key), dict):
+ for entry in schema[key].values():
+ walk(entry)
+ uuid = schema.get("uuid")
+ if uuid and uuid not in (CATEGORY_CATEGORY_UUID, CATEGORY_ENTITY_UUID):
+ title = "Category:" + get_osw_id(uuid)
+ if title not in templates:
+ templates.append(title)
+
+ for title in meta_category_titles:
+ visited.add(title)
+ walk(schema_of(title))
+ return templates
+
+
+def reset_generated_jsonld_context(
+ schema: Optional[dict], had_generated_section: bool
+) -> None:
+ """Empties the generated first object block of the @context array.
+
+ Port of osl.util.resetGeneratedJsonLdContext in PageBot.js. The generated
+ property mappings are by convention the first object entry of an @context
+ array, the strings before it are parent schema references. Without a generated
+ section the first object block is hand written, so an empty block is inserted
+ ahead of it. A string or object @context is left as it is. Modifies schema in
+ place.
+ """
+ if not schema:
+ return
+ context = schema.get("@context")
+ if not isinstance(context, list):
+ return
+ index = next((i for i, e in enumerate(context) if isinstance(e, dict)), -1)
+ if not had_generated_section:
+ context.insert(len(context) if index == -1 else index, {})
+ return
+ if index != -1:
+ context[index] = {}
+ else:
+ context.append({})
+
+
+def merge_generated_jsonld_context(schema: dict, generated_context: Any) -> None:
+ """Merges the @context of a generated schema into the schema.
+
+ Port of the @context handling of osl.util.postProcessPage in PageBot.js. The
+ generated mappings are merged into the first object block of the @context
+ array only, which reset_generated_jsonld_context() reserved. oold's
+ aggregate_generated_schemas() collapses all object blocks into one, which
+ would fuse a hand written block with the generated one. String entries
+ (parent schema references) of the generated context are appended when new.
+ Modifies schema in place.
+ """
+ existing = schema.get("@context")
+ if isinstance(existing, list):
+ context_list = existing
+ else:
+ context_list = [existing] if existing else []
+ entries = (
+ generated_context
+ if isinstance(generated_context, list)
+ else [generated_context]
+ )
+ generated_object: dict = {}
+ for entry in entries:
+ if isinstance(entry, dict):
+ generated_object = merge_deep(generated_object, entry)
+ elif entry and entry not in context_list:
+ context_list.append(entry)
+ index = next((i for i, e in enumerate(context_list) if isinstance(e, dict)), -1)
+ if index == -1:
+ context_list.append(generated_object)
+ else:
+ context_list[index] = merge_deep(context_list[index], generated_object)
+ schema["@context"] = context_list
+
+
+def strip_root_members_superseded_by_generated(
+ root_schema: dict, generated_schema: dict
+) -> bool:
+ """Removes the root members the generated section defines.
+
+ Port of osl.util.stripRootMembersSupersededByGenerated in PageBot.js. A member
+ declared at root takes precedence over the same member pulled in through
+ allOf, so a copy of the generated members at root would shadow the freshly
+ generated ones. Containers are searched recursively, manual siblings stay.
+ Members that are written to root on purpose are skipped. The allOf entries that
+ the generated section repeats are removed from root. Modifies root_schema in
+ place and returns True when anything was removed.
+ """
+ promoted_to_root = {
+ "title",
+ "title*",
+ "description",
+ "description*",
+ "@context",
+ "$defs",
+ "allOf",
+ "$ref",
+ "$comment",
+ }
+ removed = False
+
+ def strip(target, generated, is_root):
+ nonlocal removed
+ if not isinstance(target, dict) or not isinstance(generated, dict):
+ return
+ for key in list(generated.keys()):
+ if is_root and key in promoted_to_root:
+ continue
+ if key not in target:
+ continue
+ if isinstance(generated[key], dict) and isinstance(target[key], dict):
+ strip(target[key], generated[key], False)
+ if len(target[key]) == 0:
+ del target[key]
+ removed = True
+ else:
+ del target[key]
+ removed = True
+
+ strip(root_schema, generated_schema, True)
+
+ generated_allof = generated_schema.get("allOf")
+ generated_allof = generated_allof if isinstance(generated_allof, list) else []
+ if isinstance(root_schema.get("allOf"), list) and generated_allof:
+ generated_refs = [
+ e["$ref"] for e in generated_allof if isinstance(e, dict) and e.get("$ref")
+ ]
+ kept = [
+ e
+ for e in root_schema["allOf"]
+ if not (isinstance(e, dict) and e.get("$ref") in generated_refs)
+ ]
+ if len(kept) != len(root_schema["allOf"]):
+ root_schema["allOf"] = kept
+ removed = True
+ return removed
+
+
# Reusable type definitions
class OverwriteOptions(Enum):
"""Options for overwriting properties"""
@@ -1984,33 +2198,34 @@ def store_entity(
"""Titles of the pages an edit was sent for, to be verified below."""
meta_category_templates = {}
+ definitions_section = False
if param.namespace == "Category":
meta_category_titles = param.meta_category_title
if not isinstance(meta_category_titles, list):
meta_category_titles = [meta_category_titles]
- meta_category_template_strs = {}
- # We have to do this iteratively to support meta categories inheritance
- while meta_category_titles is not None and len(meta_category_titles) > 0:
- meta_categories = self.site.get_page(
- WtSite.GetPageParam(titles=meta_category_titles)
- ).pages
- for meta_category in meta_categories:
- meta_category_template_strs[meta_category.title] = (
- meta_category.get_slot_content("schema_template")
- )
- meta_category_titles = meta_category.get_slot_content("jsondata").get(
- "subclass_of"
- )
+ def get_pages(titles):
+ return self.site.get_page(WtSite.GetPageParam(titles=titles)).pages
- for title in meta_category_template_strs.keys():
- meta_category_template_str = meta_category_template_strs[title]
- if meta_category_template_str:
- meta_category_templates[title] = compile_handlebars_template(
- meta_category_template_str
+ # The templates follow the allOf chain of the schema of the meta
+ # categories, like the form editor does. subclass_of is not used:
+ # Category:Category has no subclass_of but an allOf to CharacteristicType
+ template_titles = collect_category_template_titles(
+ get_pages, meta_category_titles
+ )
+ for page in get_pages(template_titles):
+ template_str = page.get_slot_content("schema_template")
+ if template_str:
+ meta_category_templates[page.title] = compile_handlebars_template(
+ template_str
)
- # inverse order to have the most generic template first
- meta_category_templates = dict(reversed(meta_category_templates.items()))
+ definitions_section = CHARACTERISTIC_TYPE_TITLE in template_titles
+ # keep the order of the list, the most generic template first
+ meta_category_templates = {
+ t: meta_category_templates[t]
+ for t in template_titles
+ if t in meta_category_templates
+ }
def store_entity_(
entity_: model.Entity,
@@ -2092,29 +2307,30 @@ def store_entity_(
)
mode = AggregateGeneratedSchemasParamMode.ROOT_LEVEL
- # Put generated schema in definitions section,
- # currently only enabled for Characteristics. The class is
- # either the locally generated one or the one shipped by the
- # opensemantic packages, depending on where the model is from.
- characteristic_types = tuple(
- t
- for t in (
- getattr(model, "CharacteristicType", None),
- _OpensemanticCharacteristicType,
- )
- if t is not None
- )
- if characteristic_types and isinstance(entity_, characteristic_types):
+ # Put the generated schema in the definitions section when the
+ # templates include CharacteristicType, like the form editor does.
+ if definitions_section:
mode = AggregateGeneratedSchemasParamMode.DEFINITIONS_SECTION
+ definitions = (
+ mode == AggregateGeneratedSchemasParamMode.DEFINITIONS_SECTION
+ )
stored_schema = page.get_slot_content("jsonschema") or {}
# Regenerate the generated section from scratch. Skip this when
# the generation failed, so the stored section is kept.
- if (
- mode == AggregateGeneratedSchemasParamMode.DEFINITIONS_SECTION
- and len(generated_schemas) > 0
- ):
+ if definitions and len(generated_schemas) > 0:
+ reset_generated_jsonld_context(
+ stored_schema,
+ isinstance(stored_schema.get("$defs"), dict)
+ and "generated" in stored_schema["$defs"],
+ )
remove_generated_schema_section(stored_schema)
+ if definitions:
+ for generated_schema in generated_schemas.values():
+ if "@context" in generated_schema:
+ merge_generated_jsonld_context(
+ stored_schema, generated_schema.pop("@context")
+ )
new_schema = aggregate_generated_schemas(
AggregateGeneratedSchemasParam(
target_schema=stored_schema,
@@ -2122,6 +2338,12 @@ def store_entity_(
mode=mode,
)
).aggregated_schema
+ if definitions and isinstance(
+ (new_schema.get("$defs") or {}).get("generated"), dict
+ ):
+ strip_root_members_superseded_by_generated(
+ new_schema, new_schema["$defs"]["generated"]
+ )
page.set_slot_content("jsonschema", new_schema)
if param.offline is False and not kept_existing:
page.edit(
diff --git a/tests/fixtures/category_template_selection/after-form-editor-save.json b/tests/fixtures/category_template_selection/after-form-editor-save.json
new file mode 100644
index 0000000..58840d7
--- /dev/null
+++ b/tests/fixtures/category_template_selection/after-form-editor-save.json
@@ -0,0 +1,568 @@
+{
+ "Category:OSW37a57741ae2e4dd4b29b1172b6848be8": {
+ "jsondata": {
+ "subclass_of": [
+ "Category:Item"
+ ],
+ "type": [
+ "Category:Category"
+ ],
+ "version": "0.1.0",
+ "label": [
+ {
+ "text": "Physical Item",
+ "lang": "en"
+ },
+ {
+ "text": "Physischer Gegenstand",
+ "lang": "de"
+ }
+ ],
+ "characteristics": [
+ "Category:OSW42d82cfc0c304ca48b21e72557a5472f"
+ ],
+ "uuid": "37a57741-ae2e-4dd4-b29b-1172b6848be8",
+ "description": [
+ {
+ "text": "Physical item as opposed to intangible item. It can posses properties such as position, geometry, weight, volume , etc. ",
+ "lang": "en"
+ },
+ {
+ "text": "Physischer Gegenstand im Gegensatz zu immateriellen Gegenständen. Er kann Eigenschaften wie Position, Geometrie, Gewicht, Volumen usw. besitzen. ",
+ "lang": "de"
+ }
+ ],
+ "name": "PhysicalItem",
+ "metaclass": [
+ "Category:OSWca99a9bb9ad24386b222d18a73c2af5e"
+ ],
+ "utf8_icon": [
+ "🪨"
+ ]
+ },
+ "jsonschema": {
+ "@context": [
+ "/wiki/Category:Item?action=raw&slot=jsonschema",
+ {},
+ "/wiki/Category:OSW42d82cfc0c304ca48b21e72557a5472f?action=raw&slot=jsonschema",
+ {}
+ ],
+ "allOf": [
+ {
+ "$ref": "#/$defs/generated"
+ }
+ ],
+ "title": "PhysicalItem",
+ "title*": {
+ "en": "Physical Item",
+ "de": "Physischer Gegenstand"
+ },
+ "description": "Physical item as opposed to intangible item. It can posses properties such as position, geometry, weight, volume , etc. ",
+ "description*": {
+ "en": "Physical item as opposed to intangible item. It can posses properties such as position, geometry, weight, volume , etc. ",
+ "de": "Physischer Gegenstand im Gegensatz zu immateriellen Gegenständen. Er kann Eigenschaften wie Position, Geometrie, Gewicht, Volumen usw. besitzen. "
+ },
+ "properties": {
+ "quantity_statements": {
+ "type": "array",
+ "format": "tabs",
+ "title": "Quantitative statement(s)",
+ "title*": {
+ "de": "Quantitative Aussage(n)"
+ },
+ "eval_template": {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{#quantity_statements}}.. {{#quantity}}[[{{quantity}}]]{{/quantity}} {{#value}}{{value}}{{/value}}
{{/quantity_statements}}"
+ },
+ "items": {
+ "$ref": "/wiki/JsonSchema:QuantityProperty?action=raw"
+ }
+ },
+ "origin": {
+ "type": "array",
+ "format": "table",
+ "title": "Origin",
+ "title*": {
+ "de": "Ursprung"
+ },
+ "description": "Other physical item(s) that this physical item was derived from.",
+ "description*": {
+ "de": "Andere(r) physische(r) Gegenstand/Gegenstände aus der dieser physische Gegenstand hergestellt wurde."
+ },
+ "options": {
+ "enable_array_copy": true,
+ "array_copy_ignore": [
+ "uuid",
+ "attachments"
+ ]
+ },
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}}
<%#origin%>- [[<%origin_item_instance%>]] ({{#switch:{{USERLANGUAGECODE}}|en=Type|de=Typ|#default=Type}}: {{#ask:[[<%origin_item_instance%>]]|?HasType=|format=plain|mainlabel=-}})
<%/origin%>
"
+ }
+ ],
+ "items": {
+ "type": "object",
+ "format": "grid-strict",
+ "title": "Other physical Item",
+ "title*": {
+ "de": "Anderer physischer Gegenstand"
+ },
+ "id": "origin_item",
+ "headerTemplate": "{{ i1 }}",
+ "defaultProperties": [],
+ "required": [
+ "uuid",
+ "origin_item_type",
+ "origin_item_instance_property",
+ "origin_item_instance"
+ ],
+ "properties": {
+ "uuid": {
+ "type": "string",
+ "dynamic_template": "{{_uuid_}}",
+ "options": {
+ "hidden": true,
+ "dynamic_template": {
+ "override": "empty"
+ }
+ }
+ },
+ "origin_item_type": {
+ "title": "Type",
+ "title*": {
+ "de": "Typ"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:Category",
+ "options": {
+ "autocomplete": {
+ "$comment": "Tool",
+ "query": "[[:Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]OR[[SubClassOf::Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]OR[[SubClassOf.SubClassOf::Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]OR[[SubClassOf.SubClassOf.SubClassOf::Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]OR[[SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]OR[[SubClassOf.SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]"
+ },
+ "grid_columns": 12
+ },
+ "default": "Category:OSW37a57741ae2e4dd4b29b1172b6848be8",
+ "propertyOrder": 40
+ },
+ "origin_item_instance_property": {
+ "type": "string",
+ "default": "Property:HasOrigin",
+ "options": {
+ "hidden": true
+ }
+ },
+ "origin_item_instance": {
+ "title": "Instance",
+ "title*": {
+ "de": "Instanz"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:OSW37a57741ae2e4dd4b29b1172b6848be8",
+ "watch": {
+ "origin_item_type_": "origin_item.origin_item_type"
+ },
+ "options": {
+ "autocomplete": {
+ "query": "[[HasType::$(origin_item_type_)]]|?Display_title_of=label|?HasImage=image|?HasDescription=description"
+ },
+ "grid_columns": 12
+ },
+ "propertyOrder": 50
+ }
+ }
+ }
+ },
+ "type_no": {
+ "allOf": [
+ {
+ "$ref": "/wiki/JsonSchema:TypeNumber?action=raw&slot=main"
+ }
+ ]
+ },
+ "manufacturer": {
+ "allOf": [
+ {
+ "$ref": "/wiki/JsonSchema:Manufacturer?action=raw&slot=main"
+ }
+ ]
+ },
+ "data_sheet": {
+ "$ref": "/wiki/JsonSchema:DataSheet?action=raw&slot=main"
+ },
+ "safety_data_sheet": {
+ "$ref": "/wiki/JsonSchema:SafetyDataSheet?action=raw&slot=main"
+ },
+ "manual": {
+ "$ref": "/wiki/JsonSchema:Manual?action=raw&slot=main"
+ },
+ "instructions": {
+ "$ref": "/wiki/JsonSchema:Instructions?action=raw&slot=main"
+ }
+ },
+ "$defs": {
+ "generated": {
+ "$comment": "Autogenerated section - do not edit. Generated from Category:Category Category:OSWffe74f291d354037b318c422591c5023",
+ "allOf": [
+ {
+ "$ref": "/wiki/Category:Item?action=raw&slot=jsonschema"
+ },
+ {
+ "$ref": "/wiki/Category:OSW42d82cfc0c304ca48b21e72557a5472f?action=raw&slot=jsonschema"
+ }
+ ],
+ "type": "object",
+ "uuid": "37a57741-ae2e-4dd4-b29b-1172b6848be8",
+ "x-oold-version": "0.1.0",
+ "title": "GeneratedPhysicalItem",
+ "title*": {
+ "en": "Physical Item",
+ "de": "Physischer Gegenstand"
+ },
+ "description": "This is an autogenerated partial class definition of 'PhysicalItem'",
+ "description*": {
+ "en": "Physical item as opposed to intangible item. It can posses properties such as position, geometry, weight, volume , etc. ",
+ "de": "Physischer Gegenstand im Gegensatz zu immateriellen Gegenständen. Er kann Eigenschaften wie Position, Geometrie, Gewicht, Volumen usw. besitzen. "
+ },
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "default": [
+ "Category:OSW37a57741ae2e4dd4b29b1172b6848be8"
+ ]
+ }
+ },
+ "defaultProperties": [
+ "type"
+ ]
+ }
+ }
+ },
+ "revision": {
+ "revid": 134604,
+ "parentid": 129378,
+ "user": "Lukas Gold",
+ "timestamp": "2026-11-05T12:52:22Z",
+ "comment": ""
+ }
+ },
+ "Category:OSWca99a9bb9ad24386b222d18a73c2af5e": {
+ "jsondata": {
+ "subclass_of": [
+ "Category:Category"
+ ],
+ "type": [
+ "Category:Category"
+ ],
+ "version": "0.1.0",
+ "label": [
+ {
+ "text": "Physical Item Type",
+ "lang": "en"
+ }
+ ],
+ "uuid": "ca99a9bb-9ad2-4386-b222-d18a73c2af5e",
+ "description": [
+ {
+ "text": "Helps to create subcategories of PhysicalItem",
+ "lang": "en"
+ }
+ ],
+ "name": "PhysicalItemType"
+ },
+ "jsonschema": {
+ "@context": [
+ "/wiki/Category:Category?action=raw&slot=jsonschema",
+ {},
+ {
+ "constituents": {
+ "@type": "@id",
+ "@id": "Property:HasStatement",
+ "@context": {
+ "property": "Property:HasProperty",
+ "type": "Property:HasValue"
+ }
+ },
+ "data_sheet": {
+ "@id": "Property:HasDataSheet",
+ "@type": "@id"
+ },
+ "instructions": {
+ "@id": "Property:HasInstructions",
+ "@type": "@id"
+ },
+ "manual": {
+ "@id": "Property:HasManual",
+ "@type": "@id"
+ },
+ "manufacturer": {
+ "@id": "Property:HasManufacturer",
+ "@type": "@id"
+ },
+ "safety_data_sheet": {
+ "@id": "Property:HasSafetyDataSheet",
+ "@type": "@id"
+ },
+ "type_no": "Property:HasTypeNumber"
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "#/$defs/generated"
+ }
+ ],
+ "title": "PhysicalItemType",
+ "title*": {
+ "en": "Physical Item Type"
+ },
+ "description": "Helps to create subcategories of PhysicalItem",
+ "description*": {
+ "en": "Helps to create subcategories of PhysicalItem"
+ },
+ "defaultPropertis": [
+ "type_no",
+ "manufacturer",
+ "data_sheet",
+ "safety_data_sheet",
+ "supply",
+ "specifications"
+ ],
+ "properties": {
+ "type_no": {
+ "$ref": "/wiki/JsonSchema:TypeNumber?action=raw&slot=main"
+ },
+ "manufacturer": {
+ "$ref": "/wiki/JsonSchema:Manufacturer?action=raw&slot=main"
+ },
+ "data_sheet": {
+ "$ref": "/wiki/JsonSchema:DataSheet?action=raw&slot=main"
+ },
+ "safety_data_sheet": {
+ "$ref": "/wiki/JsonSchema:SafetyDataSheet?action=raw&slot=main"
+ },
+ "instructions": {
+ "$ref": "/wiki/JsonSchema:Instructions?action=raw&slot=main"
+ },
+ "manual": {
+ "$ref": "/wiki/JsonSchema:Manual?action=raw&slot=main"
+ },
+ "supply": {
+ "title": "Supply",
+ "title*": {
+ "de": "Bezugsmöglichkeit"
+ },
+ "description": "Supply of the material with supplier, article no. and link",
+ "description*": {
+ "de": "Bezugsmöglichkeit des Materials mit Lieferant, Artikelnummer und Link"
+ },
+ "type": "array",
+ "format": "table",
+ "items": {
+ "title": "Supply",
+ "title*": {
+ "de": "Bezugsmöglichkeit"
+ },
+ "type": "object",
+ "properties": {
+ "supplier": {
+ "title": "Supplier",
+ "title*": {
+ "de": "Lieferant"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "options": {
+ "autocomplete": {
+ "category": "Category:OSW3cb8cef2225e403092f098f99bc4c472"
+ }
+ }
+ },
+ "article_no": {
+ "title": "Article no.",
+ "title*": {
+ "de": "Artikelnummer"
+ },
+ "description": "Article number in the catalogue of the supplier",
+ "description*": {
+ "de": "Artikelnummer im Katalog des Lieferanten"
+ },
+ "type": "string"
+ },
+ "comment": {
+ "title": "Comment",
+ "title*": {
+ "de": "Kommentar"
+ },
+ "description": "Comment on the supply of the material",
+ "description*": {
+ "de": "Kommentar bezüglich der Bezugsmöglichkeit des Materials"
+ },
+ "type": "string",
+ "format": ""
+ },
+ "link": {
+ "title": "Link",
+ "title*": {
+ "de": "Link"
+ },
+ "description": "Link to the article in the online catalogue of the supplier",
+ "description*": {
+ "de": "Link zum Artikel im Online-Katalog des Lieferanten"
+ },
+ "type": "string",
+ "format": "uri"
+ }
+ }
+ }
+ },
+ "specifications": {
+ "title": "Specification of technical properties",
+ "title*": {
+ "de": "Spezifikation technischer Eigenschaften"
+ },
+ "type": "array",
+ "format": "table",
+ "items": {
+ "title": "Attribute",
+ "title*": {
+ "de": "Attribut"
+ },
+ "type": "object",
+ "headerTemplate": "{{i1}} - {{self.name}}",
+ "required": [
+ "attribute_name",
+ "quantity_prop"
+ ],
+ "properties": {
+ "attribute_name": {
+ "title": "Name",
+ "type": "array",
+ "format": "table",
+ "items": {
+ "$ref": "/wiki/JsonSchema:Label?action=raw"
+ },
+ "minItems": 1
+ },
+ "quantity_prop": {
+ "$ref": "/wiki/JsonSchema:QuantityProperty?action=raw"
+ }
+ }
+ }
+ },
+ "constituents": {
+ "type": "array",
+ "format": "tabs",
+ "title": "Constituents",
+ "title*": {
+ "de": "Bestandteile"
+ },
+ "description": "Components or parts",
+ "items": {
+ "type": "object",
+ "title": "Constituent",
+ "title*": {
+ "de": "Bestandteil"
+ },
+ "headerTemplate": "{{i1}} - {{self.name}}",
+ "required": [
+ "name",
+ "type",
+ "uuid",
+ "property"
+ ],
+ "properties": {
+ "uuid": {
+ "title": "UUID",
+ "type": "string",
+ "format": "uuid",
+ "options": {
+ "hidden": true
+ }
+ },
+ "property": {
+ "type": "string",
+ "default": "Property:HasPart",
+ "options": {
+ "hidden": true
+ }
+ },
+ "is_array": {
+ "title": "Is array",
+ "type": "boolean"
+ },
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "type": {
+ "title": "Type",
+ "type": "string",
+ "format": "autocomplete",
+ "$comment": "Material",
+ "range": "Category:OSW31ca9a739cb24079b36824045c0832aa",
+ "options": {
+ "autocomplete": {
+ "$comment": "todo: we need an any-subcategory-of shortcut",
+ "query": "[[SubClassOf::Category:OSW31ca9a739cb24079b36824045c0832aa]]OR[[SubClassOf.SubClassOf::Category:OSW31ca9a739cb24079b36824045c0832aa]]OR[[SubClassOf.SubClassOf.SubClassOf::Category:OSW31ca9a739cb24079b36824045c0832aa]]OR[[SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:OSW31ca9a739cb24079b36824045c0832aa]]OR[[SubClassOf.SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:OSW31ca9a739cb24079b36824045c0832aa]]"
+ }
+ }
+ },
+ "default": {
+ "title": "Default value",
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "$defs": {
+ "generated": {
+ "$comment": "Autogenerated section - do not edit. Generated from Category:Category Category:OSWffe74f291d354037b318c422591c5023",
+ "allOf": [
+ {
+ "$ref": "/wiki/Category:Category?action=raw&slot=jsonschema"
+ }
+ ],
+ "type": "object",
+ "uuid": "ca99a9bb-9ad2-4386-b222-d18a73c2af5e",
+ "x-oold-version": "0.1.0",
+ "title": "GeneratedPhysicalItemType",
+ "title*": {
+ "en": "Physical Item Type"
+ },
+ "description": "This is an autogenerated partial class definition of 'PhysicalItemType'",
+ "description*": {
+ "en": "Helps to create subcategories of PhysicalItem"
+ },
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "default": [
+ "Category:OSWca99a9bb9ad24386b222d18a73c2af5e"
+ ]
+ }
+ },
+ "defaultProperties": [
+ "type"
+ ]
+ }
+ }
+ },
+ "revision": {
+ "revid": 134605,
+ "parentid": 122162,
+ "user": "Lukas Gold",
+ "timestamp": "2026-11-05T12:52:46Z",
+ "comment": ""
+ }
+ }
+}
diff --git a/tests/fixtures/category_template_selection/before-form-editor-save.json b/tests/fixtures/category_template_selection/before-form-editor-save.json
new file mode 100644
index 0000000..1b816fc
--- /dev/null
+++ b/tests/fixtures/category_template_selection/before-form-editor-save.json
@@ -0,0 +1,586 @@
+{
+ "Category:OSW37a57741ae2e4dd4b29b1172b6848be8": {
+ "jsondata": {
+ "subclass_of": [
+ "Category:Item"
+ ],
+ "type": [
+ "Category:Category"
+ ],
+ "label": [
+ {
+ "text": "Physical Item",
+ "lang": "en"
+ },
+ {
+ "text": "Physischer Gegenstand",
+ "lang": "de"
+ }
+ ],
+ "uuid": "37a57741-ae2e-4dd4-b29b-1172b6848be8",
+ "description": [
+ {
+ "text": "Physical item as opposed to intangible item. It can posses properties such as position, geometry, weight, volume , etc. ",
+ "lang": "en"
+ },
+ {
+ "text": "Physischer Gegenstand im Gegensatz zu immateriellen Gegenständen. Er kann Eigenschaften wie Position, Geometrie, Gewicht, Volumen usw. besitzen. ",
+ "lang": "de"
+ }
+ ],
+ "name": "PhysicalItem",
+ "metaclass": [
+ "Category:OSWca99a9bb9ad24386b222d18a73c2af5e"
+ ],
+ "utf8_icon": [
+ "🪨"
+ ],
+ "characteristics": [
+ "Category:OSW42d82cfc0c304ca48b21e72557a5472f"
+ ]
+ },
+ "jsonschema": {
+ "@context": [
+ "/wiki/Category:Item?action=raw&slot=jsonschema",
+ {
+ "data_sheet": {
+ "@id": "Property:HasDataSheet",
+ "@type": "@id"
+ },
+ "instructions": {
+ "@id": "Property:HasInstructions",
+ "@type": "@id"
+ },
+ "manual": {
+ "@id": "Property:HasManual",
+ "@type": "@id"
+ },
+ "manufacturer": {
+ "@id": "Property:HasManufacturer",
+ "@type": "@id"
+ },
+ "origin": {
+ "@id": "Property:HasStatement",
+ "@type": "@id",
+ "@context": {
+ "origin_item_instance_property": "Property:HasProperty",
+ "origin_item_instance": "Property:HasObject"
+ }
+ },
+ "quantity_statements": {
+ "@id": "Property:HasStatement",
+ "@type": "@id"
+ },
+ "safety_data_sheet": {
+ "@id": "Property:HasSafetyDataSheet",
+ "@type": "@id"
+ },
+ "serial_number": "Property:HasSerialNumber",
+ "type_no": "Property:HasTypeNumber"
+ },
+ "/wiki/Category:OSW42d82cfc0c304ca48b21e72557a5472f?action=raw&slot=jsonschema",
+ {}
+ ],
+ "allOf": [
+ {
+ "$ref": "/wiki/Category:Item?action=raw&slot=jsonschema"
+ },
+ {
+ "$ref": "#/$defs/generated"
+ }
+ ],
+ "type": "object",
+ "uuid": "37a57741-ae2e-4dd4-b29b-1172b6848be8",
+ "title": "PhysicalItem",
+ "title*": {
+ "en": "Physical Item",
+ "de": "Physischer Gegenstand"
+ },
+ "description": "Physical item as opposed to intangible item. It can posses properties such as position, geometry, weight, volume , etc. ",
+ "description*": {
+ "en": "Physical item as opposed to intangible item. It can posses properties such as position, geometry, weight, volume , etc. ",
+ "de": "Physischer Gegenstand im Gegensatz zu immateriellen Gegenständen. Er kann Eigenschaften wie Position, Geometrie, Gewicht, Volumen usw. besitzen. "
+ },
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "default": [
+ "Category:OSW37a57741ae2e4dd4b29b1172b6848be8"
+ ]
+ },
+ "quantity_statements": {
+ "type": "array",
+ "format": "tabs",
+ "title": "Quantitative statement(s)",
+ "title*": {
+ "de": "Quantitative Aussage(n)"
+ },
+ "eval_template": {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{#quantity_statements}}.. {{#quantity}}[[{{quantity}}]]{{/quantity}} {{#value}}{{value}}{{/value}}
{{/quantity_statements}}"
+ },
+ "items": {
+ "$ref": "/wiki/JsonSchema:QuantityProperty?action=raw"
+ }
+ },
+ "origin": {
+ "type": "array",
+ "format": "table",
+ "title": "Origin",
+ "title*": {
+ "de": "Ursprung"
+ },
+ "description": "Other physical item(s) that this physical item was derived from.",
+ "description*": {
+ "de": "Andere(r) physische(r) Gegenstand/Gegenstände aus der dieser physische Gegenstand hergestellt wurde."
+ },
+ "options": {
+ "enable_array_copy": true,
+ "array_copy_ignore": [
+ "uuid",
+ "attachments"
+ ]
+ },
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}} <%#origin%>- [[<%origin_item_instance%>]] ({{#switch:{{USERLANGUAGECODE}}|en=Type|de=Typ|#default=Type}}: {{#ask:[[<%origin_item_instance%>]]|?HasType=|format=plain|mainlabel=-}})
<%/origin%>
"
+ }
+ ],
+ "items": {
+ "type": "object",
+ "format": "grid-strict",
+ "title": "Other physical Item",
+ "title*": {
+ "de": "Anderer physischer Gegenstand"
+ },
+ "id": "origin_item",
+ "headerTemplate": "{{ i1 }}",
+ "defaultProperties": [],
+ "required": [
+ "uuid",
+ "origin_item_type",
+ "origin_item_instance_property",
+ "origin_item_instance"
+ ],
+ "properties": {
+ "uuid": {
+ "type": "string",
+ "dynamic_template": "{{_uuid_}}",
+ "options": {
+ "hidden": true,
+ "dynamic_template": {
+ "override": "empty"
+ }
+ }
+ },
+ "origin_item_type": {
+ "title": "Type",
+ "title*": {
+ "de": "Typ"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:Category",
+ "options": {
+ "autocomplete": {
+ "$comment": "Tool",
+ "query": "[[:Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]OR[[SubClassOf::Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]OR[[SubClassOf.SubClassOf::Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]OR[[SubClassOf.SubClassOf.SubClassOf::Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]OR[[SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]OR[[SubClassOf.SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:OSW37a57741ae2e4dd4b29b1172b6848be8]]"
+ },
+ "grid_columns": 12
+ },
+ "default": "Category:OSW37a57741ae2e4dd4b29b1172b6848be8",
+ "propertyOrder": 40
+ },
+ "origin_item_instance_property": {
+ "type": "string",
+ "default": "Property:HasOrigin",
+ "options": {
+ "hidden": true
+ }
+ },
+ "origin_item_instance": {
+ "title": "Instance",
+ "title*": {
+ "de": "Instanz"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:OSW37a57741ae2e4dd4b29b1172b6848be8",
+ "watch": {
+ "origin_item_type_": "origin_item.origin_item_type"
+ },
+ "options": {
+ "autocomplete": {
+ "query": "[[HasType::$(origin_item_type_)]]|?Display_title_of=label|?HasImage=image|?HasDescription=description"
+ },
+ "grid_columns": 12
+ },
+ "propertyOrder": 50
+ }
+ }
+ }
+ },
+ "type_no": {
+ "allOf": [
+ {
+ "$ref": "/wiki/JsonSchema:TypeNumber?action=raw&slot=main"
+ }
+ ]
+ },
+ "manufacturer": {
+ "allOf": [
+ {
+ "$ref": "/wiki/JsonSchema:Manufacturer?action=raw&slot=main"
+ }
+ ]
+ },
+ "data_sheet": {
+ "$ref": "/wiki/JsonSchema:DataSheet?action=raw&slot=main"
+ },
+ "safety_data_sheet": {
+ "$ref": "/wiki/JsonSchema:SafetyDataSheet?action=raw&slot=main"
+ },
+ "manual": {
+ "$ref": "/wiki/JsonSchema:Manual?action=raw&slot=main"
+ },
+ "instructions": {
+ "$ref": "/wiki/JsonSchema:Instructions?action=raw&slot=main"
+ }
+ },
+ "$defs": {
+ "generated": {
+ "$comment": "Autogenerated section - do not edit. Generated from Category:Category Category:OSWffe74f291d354037b318c422591c5023",
+ "allOf": [
+ {
+ "$ref": "/wiki/Category:Item?action=raw&slot=jsonschema"
+ },
+ {
+ "$ref": "/wiki/Category:OSW42d82cfc0c304ca48b21e72557a5472f?action=raw&slot=jsonschema"
+ }
+ ],
+ "type": "object",
+ "uuid": "37a57741-ae2e-4dd4-b29b-1172b6848be8",
+ "title": "GeneratedPhysicalItem",
+ "title*": {
+ "en": "Physical Item",
+ "de": "Physischer Gegenstand"
+ },
+ "description": "This is an autogenerated partial class definition of 'PhysicalItem'",
+ "description*": {
+ "en": "Physical item as opposed to intangible item. It can posses properties such as position, geometry, weight, volume , etc. ",
+ "de": "Physischer Gegenstand im Gegensatz zu immateriellen Gegenständen. Er kann Eigenschaften wie Position, Geometrie, Gewicht, Volumen usw. besitzen. "
+ },
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "default": [
+ "Category:OSW37a57741ae2e4dd4b29b1172b6848be8"
+ ]
+ }
+ },
+ "defaultProperties": [
+ "type"
+ ]
+ }
+ }
+ },
+ "revision": {
+ "revid": 129378,
+ "parentid": 113874,
+ "user": "Lukas Gold",
+ "timestamp": "2026-07-20T10:38:01Z"
+ }
+ },
+ "Category:OSWca99a9bb9ad24386b222d18a73c2af5e": {
+ "jsondata": {
+ "subclass_of": [
+ "Category:Category"
+ ],
+ "type": [
+ "Category:Category"
+ ],
+ "uuid": "ca99a9bb-9ad2-4386-b222-d18a73c2af5e",
+ "label": [
+ {
+ "text": "Physical Item Type",
+ "lang": "en"
+ }
+ ],
+ "description": [
+ {
+ "text": "Helps to create subcategories of PhysicalItem",
+ "lang": "en"
+ }
+ ],
+ "name": "PhysicalItemType"
+ },
+ "jsonschema": {
+ "@context": [
+ "/wiki/Category:Category?action=raw&slot=jsonschema",
+ {
+ "constituents": {
+ "@type": "@id",
+ "@id": "Property:HasStatement",
+ "@context": {
+ "property": "Property:HasProperty",
+ "type": "Property:HasValue"
+ }
+ },
+ "data_sheet": {
+ "@id": "Property:HasDataSheet",
+ "@type": "@id"
+ },
+ "instructions": {
+ "@id": "Property:HasInstructions",
+ "@type": "@id"
+ },
+ "manual": {
+ "@id": "Property:HasManual",
+ "@type": "@id"
+ },
+ "manufacturer": {
+ "@id": "Property:HasManufacturer",
+ "@type": "@id"
+ },
+ "safety_data_sheet": {
+ "@id": "Property:HasSafetyDataSheet",
+ "@type": "@id"
+ },
+ "type_no": "Property:HasTypeNumber"
+ }
+ ],
+ "allOf": [
+ {
+ "$ref": "/wiki/Category:Category?action=raw&slot=jsonschema"
+ }
+ ],
+ "type": "object",
+ "uuid": "ca99a9bb-9ad2-4386-b222-d18a73c2af5e",
+ "title": "PhysicalItemType",
+ "title*": {
+ "en": "Physical Item Type"
+ },
+ "description": "Helps to create subcategories of PhysicalItem",
+ "description*": {
+ "en": "Helps to create subcategories of PhysicalItem"
+ },
+ "required": [
+ "type"
+ ],
+ "defaultPropertis": [
+ "type_no",
+ "manufacturer",
+ "data_sheet",
+ "safety_data_sheet",
+ "supply",
+ "specifications"
+ ],
+ "properties": {
+ "type": {
+ "default": [
+ "Category:OSWca99a9bb9ad24386b222d18a73c2af5e"
+ ]
+ },
+ "type_no": {
+ "$ref": "/wiki/JsonSchema:TypeNumber?action=raw&slot=main"
+ },
+ "manufacturer": {
+ "$ref": "/wiki/JsonSchema:Manufacturer?action=raw&slot=main"
+ },
+ "data_sheet": {
+ "$ref": "/wiki/JsonSchema:DataSheet?action=raw&slot=main"
+ },
+ "safety_data_sheet": {
+ "$ref": "/wiki/JsonSchema:SafetyDataSheet?action=raw&slot=main"
+ },
+ "instructions": {
+ "$ref": "/wiki/JsonSchema:Instructions?action=raw&slot=main"
+ },
+ "manual": {
+ "$ref": "/wiki/JsonSchema:Manual?action=raw&slot=main"
+ },
+ "supply": {
+ "title": "Supply",
+ "title*": {
+ "de": "Bezugsmöglichkeit"
+ },
+ "description": "Supply of the material with supplier, article no. and link",
+ "description*": {
+ "de": "Bezugsmöglichkeit des Materials mit Lieferant, Artikelnummer und Link"
+ },
+ "type": "array",
+ "format": "table",
+ "items": {
+ "title": "Supply",
+ "title*": {
+ "de": "Bezugsmöglichkeit"
+ },
+ "type": "object",
+ "properties": {
+ "supplier": {
+ "title": "Supplier",
+ "title*": {
+ "de": "Lieferant"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "options": {
+ "autocomplete": {
+ "category": "Category:OSW3cb8cef2225e403092f098f99bc4c472"
+ }
+ }
+ },
+ "article_no": {
+ "title": "Article no.",
+ "title*": {
+ "de": "Artikelnummer"
+ },
+ "description": "Article number in the catalogue of the supplier",
+ "description*": {
+ "de": "Artikelnummer im Katalog des Lieferanten"
+ },
+ "type": "string"
+ },
+ "comment": {
+ "title": "Comment",
+ "title*": {
+ "de": "Kommentar"
+ },
+ "description": "Comment on the supply of the material",
+ "description*": {
+ "de": "Kommentar bezüglich der Bezugsmöglichkeit des Materials"
+ },
+ "type": "string",
+ "format": ""
+ },
+ "link": {
+ "title": "Link",
+ "title*": {
+ "de": "Link"
+ },
+ "description": "Link to the article in the online catalogue of the supplier",
+ "description*": {
+ "de": "Link zum Artikel im Online-Katalog des Lieferanten"
+ },
+ "type": "string",
+ "format": "uri"
+ }
+ }
+ }
+ },
+ "specifications": {
+ "title": "Specification of technical properties",
+ "title*": {
+ "de": "Spezifikation technischer Eigenschaften"
+ },
+ "type": "array",
+ "format": "table",
+ "items": {
+ "title": "Attribute",
+ "title*": {
+ "de": "Attribut"
+ },
+ "type": "object",
+ "headerTemplate": "{{i1}} - {{self.name}}",
+ "required": [
+ "attribute_name",
+ "quantity_prop"
+ ],
+ "properties": {
+ "attribute_name": {
+ "title": "Name",
+ "type": "array",
+ "format": "table",
+ "items": {
+ "$ref": "/wiki/JsonSchema:Label?action=raw"
+ },
+ "minItems": 1
+ },
+ "quantity_prop": {
+ "$ref": "/wiki/JsonSchema:QuantityProperty?action=raw"
+ }
+ }
+ }
+ },
+ "constituents": {
+ "type": "array",
+ "format": "tabs",
+ "title": "Constituents",
+ "title*": {
+ "de": "Bestandteile"
+ },
+ "description": "Components or parts",
+ "items": {
+ "type": "object",
+ "title": "Constituent",
+ "title*": {
+ "de": "Bestandteil"
+ },
+ "headerTemplate": "{{i1}} - {{self.name}}",
+ "required": [
+ "name",
+ "type",
+ "uuid",
+ "property"
+ ],
+ "properties": {
+ "uuid": {
+ "title": "UUID",
+ "type": "string",
+ "format": "uuid",
+ "options": {
+ "hidden": true
+ }
+ },
+ "property": {
+ "type": "string",
+ "default": "Property:HasPart",
+ "options": {
+ "hidden": true
+ }
+ },
+ "is_array": {
+ "title": "Is array",
+ "type": "boolean"
+ },
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "type": {
+ "title": "Type",
+ "type": "string",
+ "format": "autocomplete",
+ "$comment": "Material",
+ "range": "Category:OSW31ca9a739cb24079b36824045c0832aa",
+ "options": {
+ "autocomplete": {
+ "$comment": "todo: we need an any-subcategory-of shortcut",
+ "query": "[[SubClassOf::Category:OSW31ca9a739cb24079b36824045c0832aa]]OR[[SubClassOf.SubClassOf::Category:OSW31ca9a739cb24079b36824045c0832aa]]OR[[SubClassOf.SubClassOf.SubClassOf::Category:OSW31ca9a739cb24079b36824045c0832aa]]OR[[SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:OSW31ca9a739cb24079b36824045c0832aa]]OR[[SubClassOf.SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:OSW31ca9a739cb24079b36824045c0832aa]]"
+ }
+ }
+ },
+ "default": {
+ "title": "Default value",
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "revision": {
+ "revid": 122162,
+ "parentid": 122150,
+ "user": "Simon Stier",
+ "timestamp": "2026-04-04T14:42:51Z"
+ }
+ }
+}
diff --git a/tests/fixtures/category_template_selection/meta/Category.jsondata.json b/tests/fixtures/category_template_selection/meta/Category.jsondata.json
new file mode 100644
index 0000000..6743b1a
--- /dev/null
+++ b/tests/fixtures/category_template_selection/meta/Category.jsondata.json
@@ -0,0 +1,19 @@
+{
+ "label": [
+ {
+ "text": "Category (Class)",
+ "lang": "en"
+ }
+ ],
+ "uuid": "89aafe6d-ae5a-4f29-97ff-df7736d4cab6",
+ "description": [
+ {
+ "text": "Meta-Category for Categories",
+ "lang": "en"
+ }
+ ],
+ "name": "CategoryClass",
+ "type": [
+ "Category:Category"
+ ]
+}
diff --git a/tests/fixtures/category_template_selection/meta/Category.jsonschema.json b/tests/fixtures/category_template_selection/meta/Category.jsonschema.json
new file mode 100644
index 0000000..87dce7c
--- /dev/null
+++ b/tests/fixtures/category_template_selection/meta/Category.jsonschema.json
@@ -0,0 +1,115 @@
+{
+ "@context": [
+ {
+ "exact_ontology_match***": {
+ "@id": "owl:equivalentClass",
+ "@type": "@id"
+ },
+ "subclass_of": {
+ "@id": "rdfs:subClassOf",
+ "@type": "@id"
+ },
+ "subclass_of*": {
+ "@id": "Property:SubClassOf",
+ "@type": "@id"
+ },
+ "version": "owl:versionInfo",
+ "version*": "Property:HasVersion",
+ "utf8_icon": "Property:HasUtf8Icon"
+ },
+ "/wiki/Category:OSWffe74f291d354037b318c422591c5023?action=raw&slot=jsonschema"
+ ],
+ "allOf": [
+ {
+ "$ref": "/wiki/Category:OSWffe74f291d354037b318c422591c5023?action=raw&slot=jsonschema"
+ }
+ ],
+ "uuid": "89aafe6d-ae5a-4f29-97ff-df7736d4cab6",
+ "type": "object",
+ "title": "Category",
+ "title*": {
+ "de": "Kategorie (Klasse)",
+ "en": "Category (Class)"
+ },
+ "required": [
+ "subclass_of",
+ "type"
+ ],
+ "defaultProperties": [
+ "version"
+ ],
+ "properties": {
+ "type": {
+ "items": {
+ "subclassof_range": "Category:Category",
+ "format": "autocomplete",
+ "options": {
+ "autocomplete": {
+ "$comment": "All categories here since subclassof_range is limited by depth 5",
+ "query": "[[:Category:%2B]]"
+ }
+ }
+ },
+ "default": [
+ "Category:Category"
+ ],
+ "options": {
+ "hidden": true
+ }
+ },
+ "version": {
+ "title": "Version",
+ "title*": {
+ "de": "Version"
+ },
+ "description": "Human-readable version string (e.g. 1.0.1) following https://semver.org/",
+ "description*": {
+ "de": "Für Menschen lesbare Versionszeichenfolge (z.B. 1.0.1) gemäß https://semver.org/"
+ },
+ "type": "string",
+ "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$",
+ "propertyOrder": -1010,
+ "default": "0.1.0"
+ },
+ "subclass_of": {
+ "title": "Supercategories",
+ "title*": {
+ "de": "Überkategorien"
+ },
+ "type": "array",
+ "propertyOrder": -1009,
+ "items": {
+ "title": "Category",
+ "title*": {
+ "de": "Kategorie"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "subclassof_range": "Category:Entity"
+ },
+ "default": [
+ "Category:Entity"
+ ],
+ "options": {
+ "hidden": false
+ }
+ },
+ "utf8_icon": {
+ "type": "array",
+ "title": "UTF8-Icons",
+ "title*": {
+ "de": "UTF8-Symbole"
+ },
+ "description": "see https://www.unicode.org/cldr/charts/latest/annotations/romance.html",
+ "description*": {
+ "de": "siehe https://www.unicode.org/cldr/charts/latest/annotations/germanic.html"
+ },
+ "items": {
+ "type": "string",
+ "title": "UTF8-Icon",
+ "minLength": 1,
+ "maxLength": 2
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/category_template_selection/meta/Category.schema_template.txt b/tests/fixtures/category_template_selection/meta/Category.schema_template.txt
new file mode 100644
index 0000000..83b7159
--- /dev/null
+++ b/tests/fixtures/category_template_selection/meta/Category.schema_template.txt
@@ -0,0 +1,37 @@
+{
+ "@context": [
+ {{#each subclass_of}}
+ "/wiki/{{{.}}}?action=raw\u0026slot=jsonschema"{{#unless @last}},{{/unless}}
+ {{/each}}
+ ],
+ "allOf": [
+ {{#if subclass_of}}{{#each subclass_of}}
+ {"$ref": "/wiki/{{{.}}}?action=raw\u0026slot=jsonschema"}{{#unless @last}},{{/unless}}
+ {{/each}}{{else}}
+ {}
+ {{/if}}
+ ],
+ "type": "object",
+ "uuid": "{{{uuid}}}",
+ {{#if version}}"x-oold-version": "{{{version}}}",{{/if}}
+ "title": "{{{name}}}",
+ "title*": {
+ {{#each label}}
+ "{{{lang}}}": "{{{text}}}"{{#unless @last}},{{/unless}}
+ {{/each}}
+ },
+ "description": "{{{description.[0].text}}}",
+ "description*": {
+ {{#each description}}
+ "{{{lang}}}": "{{{text}}}"{{#unless @last}},{{/unless}}
+ {{/each}}
+ },
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "default": ["{{{_page_title}}}"]
+ }
+ }
+}
diff --git a/tests/fixtures/category_template_selection/meta/CharacteristicType.jsondata.json b/tests/fixtures/category_template_selection/meta/CharacteristicType.jsondata.json
new file mode 100644
index 0000000..56e3a2e
--- /dev/null
+++ b/tests/fixtures/category_template_selection/meta/CharacteristicType.jsondata.json
@@ -0,0 +1,30 @@
+{
+ "subclass_of": [
+ "Category:Entity"
+ ],
+ "type": [
+ "Category:Category"
+ ],
+ "uuid": "ffe74f29-1d35-4037-b318-c422591c5023",
+ "label": [
+ {
+ "text": "Characteristic Type",
+ "lang": "en"
+ },
+ {
+ "text": "Charakteristik-Typ",
+ "lang": "de"
+ }
+ ],
+ "description": [
+ {
+ "text": "A set of properties intended to be used in other characteristic types or classes",
+ "lang": "en"
+ },
+ {
+ "text": "Ein Satz von Eigenschaften zur Nutzung in anderen Charakteristik-Typen oder Klassen",
+ "lang": "de"
+ }
+ ],
+ "name": "CharacteristicType"
+}
diff --git a/tests/fixtures/category_template_selection/meta/CharacteristicType.jsonschema.json b/tests/fixtures/category_template_selection/meta/CharacteristicType.jsonschema.json
new file mode 100644
index 0000000..bb2110f
--- /dev/null
+++ b/tests/fixtures/category_template_selection/meta/CharacteristicType.jsonschema.json
@@ -0,0 +1,993 @@
+{
+ "@context": [
+ {
+ "subclass_of": {
+ "@id": "rdfs:subClassOf",
+ "@type": "@id"
+ },
+ "subclass_of*": {
+ "@id": "Property:SubClassOf",
+ "@type": "@id"
+ },
+ "characteristics": {
+ "@id": "Property:HasCharacteristic",
+ "@type": "@id"
+ },
+ "characteristic": {
+ "@id": "Property:HasCharacteristic",
+ "@type": "@id"
+ },
+ "properties": {
+ "@id": "Property:HasProperty",
+ "@type": "@id"
+ },
+ "metaclass": {
+ "@id": "Property:HasMetaCategory",
+ "@type": "@id"
+ }
+ },
+ "/wiki/Category:Entity?action=raw&slot=jsonschema"
+ ],
+ "allOf": [
+ {
+ "$ref": "/wiki/Category:Entity?action=raw&slot=jsonschema"
+ }
+ ],
+ "type": "object",
+ "uuid": "ffe74f29-1d35-4037-b318-c422591c5023",
+ "title": "CharacteristicType",
+ "title*": {
+ "en": "Characteristic Type",
+ "de": "Charakteristik-Typ"
+ },
+ "description": "A set of properties intended to be used in other characteristic types or classes",
+ "description*": {
+ "en": "A set of properties intended to be used in other characteristic types or classes",
+ "de": "Ein Satz von Eigenschaften zur Nutzung in anderen Charakteristik-Typen oder Klassen"
+ },
+ "required": [
+ "type",
+ "subclass_of",
+ "label"
+ ],
+ "defaultProperties": [
+ "characteristics",
+ "properties"
+ ],
+ "properties": {
+ "type": {
+ "items": {
+ "format": "autocomplete",
+ "subclassof_range": "Category:OSWffe74f291d354037b318c422591c5023",
+ "$comment": "Explicit query because subclassof_range does not include the specified category itself. Includes 5 levels of SubClassOf depth.",
+ "options": {
+ "autocomplete": {
+ "query": "[[:Category:OSWffe74f291d354037b318c422591c5023]] OR [[SubClassOf::Category:OSWffe74f291d354037b318c422591c5023]] OR [[SubClassOf.SubClassOf::Category:OSWffe74f291d354037b318c422591c5023]] OR [[SubClassOf.SubClassOf.SubClassOf::Category:OSWffe74f291d354037b318c422591c5023]] OR [[SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:OSWffe74f291d354037b318c422591c5023]] OR [[SubClassOf.SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:OSWffe74f291d354037b318c422591c5023]]"
+ }
+ }
+ },
+ "default": [
+ "Category:OSWffe74f291d354037b318c422591c5023"
+ ],
+ "options": {
+ "collapsed": true
+ }
+ },
+ "subclass_of": {
+ "title": "Supercategories (is a)",
+ "title*": {
+ "de": "Überkategorien (ist eine)"
+ },
+ "description": "Parent class(es)",
+ "description*": {
+ "de": "Elternklasse(n)"
+ },
+ "options": {
+ "$comment": "should be an $example",
+ "infoText": "A car is a vehicle"
+ },
+ "type": "array",
+ "format": "table",
+ "propertyOrder": -1009,
+ "items": {
+ "title": "Category",
+ "title*": {
+ "en": "Category",
+ "de": "Kategorie"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "$comment": "range MetaCharacteristic, subclass_of range Characteristic",
+ "range": "Category:OSWffe74f291d354037b318c422591c5023",
+ "subclassof_range": "Category:OSW93ccae36243542ceac6c951450a81d47"
+ },
+ "default": [
+ "Category:OSW93ccae36243542ceac6c951450a81d47"
+ ],
+ "minItems": 1
+ },
+ "label": {
+ "propertyOrder": -1008
+ },
+ "metaclass": {
+ "title": "Metaclasses/-categories",
+ "title*": {
+ "de": "Metaklassen/-kategorien"
+ },
+ "type": "array",
+ "format": "table",
+ "propertyOrder": -1007,
+ "items": {
+ "title": "Metaclass",
+ "title*": {
+ "de": "Metaklasse"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "subclassof_range": "Category:Category"
+ }
+ },
+ "characteristics": {
+ "title": "Characteristics (mixin)",
+ "title*": {
+ "de": "Charakteristiken (mixin)"
+ },
+ "description": "Include all properties of an existing characteristic without subclassof-relation",
+ "description*": {
+ "de": "Wiederverwendete Schema-Bestandteile ohne starke Elternklassenbeziehung"
+ },
+ "options": {
+ "$comment": "should be an $example",
+ "infoText": "A human has a physical dimension without beeing a physical dimension"
+ },
+ "$example": "",
+ "type": "array",
+ "propertyOrder": -1009,
+ "items": {
+ "title": "Characteristic",
+ "title*": {
+ "de": "Charakteristik"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "$comment": "range MetaCharacteristic, subclass_of range Characteristic",
+ "range": "Category:OSWffe74f291d354037b318c422591c5023",
+ "subclassof_range": "Category:OSW93ccae36243542ceac6c951450a81d47"
+ }
+ },
+ "properties": {
+ "title": "Own properties",
+ "title*": {
+ "de": "Eigene Attribute"
+ },
+ "description": "Properties that are defined in addition to the ones inherited by superclasses and imported characteristics",
+ "description*": {
+ "de": "Attribute die zusätzlich zu den durch Elternklassen und importierte Charakteristiken geerbten Attributen definiert werden"
+ },
+ "propertyOrder": -1009,
+ "type": "array",
+ "format": "tabs",
+ "options": {
+ "enable_array_copy": true,
+ "array_copy_ignore": [
+ "uuid"
+ ]
+ },
+ "Xeval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}} <%#properties%>* <%name%> {{#if: <%rdf_property%> |({{Viewer/Link |page=<%rdf_property%>}}) }}: {{#if: <%characteristic%> |{{Viewer/Link |page=<%characteristic%>}} }}
<%/properties%>"
+ }
+ ],
+ "items": {
+ "id": "own_property",
+ "headerTemplate": "{{ i1 }} - {{ self.name }}",
+ "required": [
+ "uuid",
+ "name"
+ ],
+ "defaultProperties": [
+ "rdf_property",
+ "label",
+ "description",
+ "required",
+ "recommended",
+ "array"
+ ],
+ "options": {
+ "copy_ignore": [
+ "uuid"
+ ]
+ },
+ "properties": {
+ "uuid": {
+ "title": "UUID",
+ "type": "string",
+ "$comment": "format uuid field are copied, if not set in copy_ignore",
+ "format": null,
+ "dynamic_template": "{{_uuid_}}",
+ "options": {
+ "hidden": true,
+ "dynamic_template": {
+ "override": "empty"
+ }
+ }
+ },
+ "name": {
+ "type": "string",
+ "pattern": "^[a-z0-9]+(_[a-z0-9]+)*$",
+ "title": "Name",
+ "description": "Technical name / keyword. Should be lowercase without spaces and special chars except underscore",
+ "description*": {
+ "de": "Technischer Name / Schlüsselwort. Sollte in Kleinbuchstaben ohne Leerzeichen und Sonderzeichen außer Unterstrich sein."
+ }
+ },
+ "label": {
+ "type": "array",
+ "title": "Label(s)",
+ "title*": {
+ "de": "Bezeichnung(en)"
+ },
+ "description": "At least one label is required.",
+ "description*": {
+ "de": "Mindestens eine Bezeichnung ist erforderlich."
+ },
+ "format": "table",
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "$comment": "Displays value according to user language with eng as fallback option. Note: {{=<% %>=}} changes mustache expression from {{..}} to <% %> for mixing with wikitext templates",
+ "value": "{{=<% %>=}} {{#switch:{{USERLANGUAGECODE}} <%#label%> | {{#ifeq: <%lang%>|en|#default|<%lang%>}} = <%text%> <%/label%> }}"
+ }
+ ],
+ "items": {
+ "$ref": "/wiki/JsonSchema:Label?action=raw"
+ },
+ "minItems": 1,
+ "options": {
+ "grid_columns": 12
+ }
+ },
+ "description": {
+ "title": "Description",
+ "title*": {
+ "de": "Beschreibung"
+ },
+ "type": "array",
+ "format": "table",
+ "eval_template": {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}} {{#switch:{{USERLANGUAGECODE}} <%={{ }}=%> {{#description}} |{{lang}}={{text}} {{/description}} {{=<% %>=}} }}"
+ },
+ "items": {
+ "$ref": "/wiki/JsonSchema:Description?action=raw"
+ },
+ "options": {
+ "grid_columns": 12
+ }
+ },
+ "rdf_property": {
+ "type": "string",
+ "title": "Internal vocabulary mapping",
+ "title*": {
+ "de": "Interne Vokabular-Zuordnung"
+ },
+ "description": "Maps a property keyword to an internal RDF property as defined in JSON-LD. Required to query this information, e.g., for tabular or graph views. Also adopts value range and data type restrictions of the property if defined.",
+ "description*": {
+ "de": "Ordnet gemäß JSON-LD einem Eigenschafts-Schlüsselwort ein internes RDF-Property zu. Wird benötigt um diese Information abzufragen, etwa für tabellarische oder Graph-Ansichten. Übernimmt auch die Wertebereichs- und Datentypbeschränkungen des Propertys, falls definiert."
+ },
+ "format": "autocomplete",
+ "range": "Category:Property",
+ "watch": {
+ "label": "own_property.label",
+ "description": "own_property.description",
+ "range": "own_property.range"
+ },
+ "options": {
+ "autocomplete": {
+ "query": "[[Category:Property]]|?HasLabel=label|?HasDescription=description|?HasRange=range",
+ "field_maps": [
+ {
+ "source_path": "$",
+ "template": "[{{#each result.printouts.label}}{\"text\": \"{{{.}}}\"}{{/each}}]",
+ "target_path": "$(label)"
+ },
+ {
+ "source_path": "$",
+ "$comment": "multilang properties are normalized",
+ "template_multilang": "[{{#each result.printouts.description}}{\"text\": \"{{{Text.item.[0]}}}\", \"lang\": \"{{{[Language code].item.[0]}}}\"}{{/each}}]",
+ "template": "[{{#each result.printouts.description}}{\"text\": \"{{{.}}}\"}{{/each}}]",
+ "target_path": "$(description)"
+ },
+ {
+ "__disabled__source_path": "$..range..fulltext",
+ "source_path": "$",
+ "template": "[\"{{{result.printouts.range.[0].fulltext}}}\"]",
+ "target_path": "$(range)"
+ }
+ ]
+ }
+ }
+ },
+ "external_rdf_properties": {
+ "title": "External Vocabulary mapping",
+ "title*": {
+ "de": "Externe Vokabular-Zuordnung"
+ },
+ "description": "Maps a property keyword to an external RDF property as defined in JSON-LD. Required to exchange data with other systems using those properties in its vocabulary.",
+ "description*": {
+ "de": "Ordnet gemäß JSON-LD einem Eigenschafts-Schlüsselwort ein RDF-Property zu. Erforderlich für den Datenaustausch mit anderen Systemen, die diese Properties in ihrem Vokabular verwenden."
+ },
+ "type": "array",
+ "items": {
+ "$comment": "ToDo: Create context notation with '*'. ToDo: Should validate IRIs (https://schema.org/xxx, schema:xxx, Property:xxx)",
+ "type": "string",
+ "options": {
+ "compact": true
+ }
+ }
+ },
+ "required": {
+ "type": "boolean",
+ "format": "checkbox",
+ "title": "Required",
+ "title*": {
+ "de": "Verpflichtend"
+ },
+ "description": "Indicates whether this field is mandatory. Mandatory properties are always displayed in the editor.",
+ "description*": {
+ "de": "Gibt an, ob dieses Attribut erforderlich ist."
+ },
+ "default": false
+ },
+ "recommended": {
+ "type": "boolean",
+ "format": "checkbox",
+ "title": "Recommended",
+ "title*": {
+ "de": "Empfohlen"
+ },
+ "description": "Indicates whether this field is suggested but not required. If properties are recommended they are displayed by default. Otherwise they must be selected explicitly by the user",
+ "description*": {
+ "de": "Gibt an, ob dieses Attribut vorgeschlagen wird aber nicht verpflichend ist. Wenn Attribute empfohlen sind werden sie standardmäßig angezeigt. Andernfalls müssen sie vom Nutzer explizit ausgewählt werden."
+ },
+ "default": true
+ },
+ "array": {
+ "type": "boolean",
+ "format": "checkbox",
+ "title": "Array",
+ "title*": {
+ "de": "Liste"
+ },
+ "description": "Indicates whether this property can contain multiple values.",
+ "description*": {
+ "de": "Gibt an, ob dieses Attribut mehrere Werte enthalten kann."
+ },
+ "default": false
+ }
+ },
+ "oneOf": [
+ {
+ "title": "NumberProperty",
+ "title*": {
+ "de": "Zahlenwert-Attribut"
+ },
+ "description": "Attribute for numerical values (integers or decimals)",
+ "description*": {
+ "de": "Attribut für Zahlenwerte (ganze Zahlen oder Dezimalzahlen)"
+ },
+ "required": [
+ "type"
+ ],
+ "defaultProperties": [],
+ "properties": {
+ "type": {
+ "title": "Number Type",
+ "title*": {
+ "de": "Zahlentyp"
+ },
+ "description": "Type of number: whole numbers or decimals",
+ "description*": {
+ "de": "Zahlentyp: ganze Zahlen oder Dezimalzahlen"
+ },
+ "type": "string",
+ "enum": [
+ "number",
+ "integer"
+ ],
+ "default": "number",
+ "options": {
+ "enum_titles": [
+ "Decimals (number)",
+ "Whole numbers (integer)"
+ ],
+ "enum_titles*": {
+ "de": [
+ "Dezimalzahlen (number)",
+ "Ganze Zahlen (integer)"
+ ]
+ }
+ }
+ },
+ "minimum": {
+ "title": "Minimum",
+ "title*": {
+ "de": "Minimum"
+ },
+ "description": "Minimum allowed value (inclusive)",
+ "description*": {
+ "de": "Minimal erlaubter Wert (einschließlich)"
+ },
+ "type": "number"
+ },
+ "maximum": {
+ "title": "Maximum",
+ "title*": {
+ "de": "Maximum"
+ },
+ "description": "Maximum allowed value (inclusive)",
+ "description*": {
+ "de": "Maximal erlaubter Wert (einschließlich)"
+ },
+ "type": "number"
+ },
+ "exclusive_minimum": {
+ "title": "Exclusive minimum",
+ "title*": {
+ "de": "Exklusives Minimum"
+ },
+ "description": "Value must be strictly greater than this",
+ "description*": {
+ "de": "Wert muss strikt größer als dieser sein"
+ },
+ "type": "number"
+ },
+ "exclusive_maximum": {
+ "title": "Exclusive maximum",
+ "title*": {
+ "de": "Exklusives Maximum"
+ },
+ "description": "Value must be strictly less than this",
+ "description*": {
+ "de": "Wert muss strikt kleiner als dieser sein"
+ },
+ "type": "number"
+ },
+ "multiple_of": {
+ "title": "Multiple of",
+ "title*": {
+ "de": "Vielfaches von"
+ },
+ "description": "Value must be a multiple of this number (e.g., step size)",
+ "description*": {
+ "de": "Wert muss ein Vielfaches dieser Zahl sein (z. B. Schrittweite)"
+ },
+ "type": "number",
+ "exclusiveMinimum": 0
+ },
+ "enum": {
+ "title": "Allowed values",
+ "title*": {
+ "de": "Erlaubte Werte"
+ },
+ "description": "Predefined list of allowed numerical values",
+ "description*": {
+ "de": "Vordefinierte Liste erlaubter Zahlenwerte"
+ },
+ "type": "array",
+ "items": {
+ "type": "number"
+ }
+ },
+ "default": {
+ "title": "Default value",
+ "title*": {
+ "de": "Standardwert"
+ },
+ "description": "Default value for this property",
+ "description*": {
+ "de": "Standardwert für dieses Attribut"
+ },
+ "type": "number"
+ }
+ }
+ },
+ {
+ "title": "TextProperty",
+ "title*": {
+ "de": "Text-Attribut"
+ },
+ "description": "Attribute for textual values",
+ "description*": {
+ "de": "Attribut für Textwerte"
+ },
+ "required": [
+ "type"
+ ],
+ "defaultProperties": [],
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "string",
+ "default": "string",
+ "options": {
+ "hidden": "true"
+ }
+ },
+ "format": {
+ "title": "Format",
+ "title*": {
+ "de": "Format"
+ },
+ "description": "Display and validation format for string values",
+ "description*": {
+ "de": "Anzeige- und Validierungsformat für Textwerte"
+ },
+ "type": "string",
+ "enum": [
+ "date",
+ "date-time",
+ "time",
+ "email",
+ "uri",
+ "color"
+ ],
+ "options": {
+ "enum_titles": [
+ "Date (date)",
+ "Date and time (date-time)",
+ "Time (time)",
+ "Email (email)",
+ "URI (uri)",
+ "Color (color)"
+ ],
+ "enum_titles*": {
+ "de": [
+ "Datum (date)",
+ "Datum und Uhrzeit (date-time)",
+ "Uhrzeit (time)",
+ "E-Mail (email)",
+ "URI (uri)",
+ "Farbe (color)"
+ ]
+ }
+ }
+ },
+ "pattern": {
+ "title": "Regular expression",
+ "title*": {
+ "de": "Regulärer Ausdruck"
+ },
+ "description": "A regular expression pattern the value must match",
+ "description*": {
+ "de": "Ein regulärer Ausdruck, dem der Wert entsprechen muss"
+ },
+ "type": "string"
+ },
+ "min_length": {
+ "title": "Minimum length",
+ "title*": {
+ "de": "Mindestlänge"
+ },
+ "description": "Minimum number of characters",
+ "description*": {
+ "de": "Mindestanzahl von Zeichen"
+ },
+ "type": "integer",
+ "minimum": 0
+ },
+ "max_length": {
+ "title": "Maximum length",
+ "title*": {
+ "de": "Maximallänge"
+ },
+ "description": "Maximum number of characters",
+ "description*": {
+ "de": "Maximale Anzahl von Zeichen"
+ },
+ "type": "integer",
+ "minimum": 0
+ },
+ "enum": {
+ "title": "Allowed values",
+ "title*": {
+ "de": "Erlaubte Werte"
+ },
+ "description": "Predefined list of allowed values",
+ "description*": {
+ "de": "Vordefinierte Liste erlaubter Werte"
+ },
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "default": {
+ "title": "Default value",
+ "title*": {
+ "de": "Standardwert"
+ },
+ "description": "Default value for this property",
+ "description*": {
+ "de": "Standardwert für dieses Attribut"
+ },
+ "type": "string"
+ }
+ }
+ },
+ {
+ "title": "BooleanProperty",
+ "title*": {
+ "de": "Wahrheitswert-Attribut"
+ },
+ "description": "Attribute for true/false values",
+ "description*": {
+ "de": "Attribut für Wahrheitswerte (wahr/falsch)"
+ },
+ "required": [
+ "type"
+ ],
+ "defaultProperties": [],
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "boolean",
+ "default": "boolean",
+ "options": {
+ "hidden": "true"
+ }
+ },
+ "default": {
+ "title": "Default value",
+ "title*": {
+ "de": "Standardwert"
+ },
+ "description": "Default value for this property",
+ "description*": {
+ "de": "Standardwert für dieses Attribut"
+ },
+ "type": "boolean",
+ "format": "checkbox"
+ }
+ }
+ },
+ {
+ "title": "LinkProperty",
+ "title*": {
+ "de": "Verweis-Attribut"
+ },
+ "description": "Attribute that links to another entity via autocomplete",
+ "description*": {
+ "de": "Attribut, das über Autovervollständigung auf eine andere Entität verweist"
+ },
+ "required": [
+ "type"
+ ],
+ "defaultProperties": [
+ "range"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "iri",
+ "default": "iri",
+ "options": {
+ "hidden": "true"
+ }
+ },
+ "range": {
+ "title": "Autocomplete value range",
+ "title*": {
+ "de": "Wertebereich für Auto-Vervollständigung"
+ },
+ "description": "Enables autocomplete support by looking up all elements that are in at least one of the listed categories. Currently only a single category is supported.",
+ "description*": {
+ "de": "Ermöglicht die Unterstützung der automatischen Vervollständigung durch Nachschlagen aller Elemente, die mindestens in einer der aufgeführten Kategorien enthalten sind. Derzeit wird nur eine einzelne Kategorie unterstützt."
+ },
+ "type": "array",
+ "maxItems": 1,
+ "items": {
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:Category",
+ "subclassof_range": "Category:Item",
+ "options": {
+ "autocomplete": {
+ "query": "[[:Category:%2B]]"
+ }
+ }
+ }
+ },
+ "subclassof_range": {
+ "title": "Subclass value range",
+ "title*": {
+ "de": "Wertebereich für Unterklassen"
+ },
+ "description": "Offer the subclasses of this class instead of instances.",
+ "description*": {
+ "de": "Bietet die Unterklassen dieser Klasse an statt Instanzen."
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:Category"
+ },
+ "query": {
+ "title": "Custom autocomplete query",
+ "title*": {
+ "de": "Eigene Abfrage für die Auto-Vervollständigung"
+ },
+ "description": "Semantic MediaWiki query that replaces the generated one. Use only when range and subclass value range cannot express the constraint.",
+ "description*": {
+ "de": "Semantic-MediaWiki-Abfrage, die die erzeugte Abfrage ersetzt. Nur verwenden, wenn Wertebereich und Unterklassen-Wertebereich nicht ausreichen."
+ },
+ "$comment": "This will allow complex expressions like '(A & B) | C' in the future",
+ "type": "string"
+ }
+ }
+ },
+ {
+ "title": "ComplexProperty",
+ "title*": {
+ "en": "Complex property",
+ "de": "Komplexes Attribut"
+ },
+ "description": "Attribute that contains a subobject, defined by another characteristic type or class",
+ "description*": {
+ "de": "Attribut, das auf ein Subobjekt verweist, das durch eine andere Charakteristik oder Klasse definiert ist"
+ },
+ "required": [
+ "type",
+ "characteristic"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "object",
+ "default": "object",
+ "options": {
+ "hidden": "true"
+ }
+ },
+ "characteristic": {
+ "title": "Characteristic",
+ "title*": {
+ "de": "Charakteristik"
+ },
+ "description": "The characteristic or class defining the object this property points to",
+ "description*": {
+ "de": "Die Charakteristik oder Klasse die das Objekt spezifiziert auf die das Attribut zeigt"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "$comment": "range MetaCharacteristic, subclass_of range Characteristic for convenience. Should allow also classes in general.",
+ "range": "Category:OSWffe74f291d354037b318c422591c5023",
+ "subclassof_range": "Category:OSW93ccae36243542ceac6c951450a81d47",
+ "#range": "Category:Category",
+ "#subclassof_range": "Category:Item"
+ },
+ "rdf_property": {
+ "range": "Category:ObjectProperty",
+ "default": "Property:HasCharacteristic"
+ },
+ "default_unit": {
+ "title": "Default unit override",
+ "title*": {
+ "de": "Standard-Einheit (Überschreibung)"
+ },
+ "description": "Override the default display unit of the referenced characteristic (only relevant for quantity-based characteristics)",
+ "description*": {
+ "de": "Überschreibt die Standard-Anzeigeeinheit der referenzierten Charakteristik (nur relevant für mengenbasierte Charakteristiken)"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "watch": {
+ "characteristic_w": "own_property.characteristic"
+ },
+ "$comment": "Query finds units via the characteristic's SubClassOf chain to the fundamental quantity. Returns empty for non-quantity characteristics (self-guarding).",
+ "options": {
+ "autocomplete": {
+ "query": "[[-HasUnit.-HasQuantity::{{$(characteristic_w)}}]][[HasSymbol::like:*{{_user_input}}*]]OR[[-HasUnit.-HasQuantity.-SubClassOf::{{$(characteristic_w)}}]][[HasSymbol::like:*{{_user_input}}*]]OR[[-HasUnit.-HasQuantity.-SubClassOf.-SubClassOf::{{$(characteristic_w)}}]][[HasSymbol::like:*{{_user_input}}*]]OR[[-HasUnit.-HasQuantity.-SubClassOf.-SubClassOf.-SubClassOf::{{$(characteristic_w)}}]][[HasSymbol::like:*{{_user_input}}*]]|?HasSymbol=label"
+ }
+ }
+ }
+ }
+ },
+ {
+ "title": "TypedInstanceProperty",
+ "title*": {
+ "en": "Typed instance property",
+ "de": "Typisiertes Instanz-Attribut"
+ },
+ "description": "Attribute where the user first picks a subclass and then an instance of that subclass. Generates the type picker, the instance picker, the narrowing query and the JSON-LD context.",
+ "description*": {
+ "de": "Attribut, bei dem zuerst eine Unterklasse und dann eine Instanz dieser Unterklasse gewählt wird. Erzeugt Typ-Auswahl, Instanz-Auswahl, einschränkende Abfrage und JSON-LD-Kontext."
+ },
+ "required": [
+ "type",
+ "instance_property"
+ ],
+ "defaultProperties": [
+ "base_class",
+ "instance_property",
+ "default_type"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "typed_instance",
+ "default": "typed_instance",
+ "options": {
+ "hidden": "true"
+ }
+ },
+ "base_class": {
+ "title": "Base class",
+ "title*": {
+ "de": "Basisklasse"
+ },
+ "description": "The class whose subclasses the type picker offers. Give either this or \"base class from field\".",
+ "description*": {
+ "de": "Die Klasse, deren Unterklassen zur Auswahl stehen."
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:Category",
+ "subclassof_range": "Category:Item"
+ },
+ "instance_property": {
+ "title": "Instance property",
+ "title*": {
+ "de": "Instanz-Attribut"
+ },
+ "description": "The semantic edge between this entity and the selected instance.",
+ "description*": {
+ "de": "Die semantische Kante zwischen dieser Entität und der gewählten Instanz."
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:ObjectProperty"
+ },
+ "default_type": {
+ "title": "Default subclass",
+ "title*": {
+ "de": "Vorausgewählte Unterklasse"
+ },
+ "description": "Optional. Preselects a subclass and narrows the instance picker before the user chooses.",
+ "description*": {
+ "de": "Optional. Wählt eine Unterklasse vor und schränkt die Instanz-Auswahl vorab ein."
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:Category"
+ },
+ "subfield_prefix": {
+ "title": "Subfield name prefix",
+ "title*": {
+ "de": "Präfix der Unterfelder"
+ },
+ "description": "Optional. Prefix of the generated subfield names, which are stored in page data. Defaults to the property name. Set it only to keep the names an existing property already uses.",
+ "description*": {
+ "de": "Optional. Präfix der erzeugten Unterfeldnamen, die in den Seitendaten gespeichert werden. Standard ist der Attributname. Nur setzen, um vorhandene Namen zu erhalten."
+ },
+ "type": "string",
+ "pattern": "^[a-z_]+$"
+ },
+ "context_label": {
+ "title": "Include label and description",
+ "title*": {
+ "de": "Bezeichnung und Beschreibung einschließen"
+ },
+ "description": "Optional. Adds label and description subfields, for naming the role the referenced instance plays here.",
+ "description*": {
+ "de": "Optional. Fügt Unterfelder für Bezeichnung und Beschreibung hinzu."
+ },
+ "type": "boolean",
+ "format": "checkbox",
+ "default": false
+ },
+ "extra_subfields": {
+ "title": "Extra subfields",
+ "title*": {
+ "de": "Zusätzliche Unterfelder"
+ },
+ "description": "Escape hatch. Additional subfields stored inside each entry, beside the generated ones.",
+ "description*": {
+ "de": "Notausgang. Zusätzliche Unterfelder, die neben den erzeugten in jedem Eintrag gespeichert werden."
+ },
+ "type": "array",
+ "format": "table",
+ "items": {
+ "type": "object",
+ "required": [
+ "name",
+ "type"
+ ],
+ "properties": {
+ "name": {
+ "title": "Name",
+ "type": "string",
+ "pattern": "^[a-z_][a-z0-9_]*$"
+ },
+ "title": {
+ "title": "Label",
+ "title*": {
+ "de": "Bezeichnung"
+ },
+ "description": "Optional. Defaults to the name.",
+ "type": "string"
+ },
+ "type": {
+ "title": "Type",
+ "title*": {
+ "de": "Typ"
+ },
+ "type": "string",
+ "enum": [
+ "string",
+ "number",
+ "integer",
+ "boolean"
+ ],
+ "default": "string"
+ },
+ "array": {
+ "title": "Multiple values",
+ "title*": {
+ "de": "Mehrere Werte"
+ },
+ "type": "boolean",
+ "format": "checkbox",
+ "default": false
+ }
+ }
+ }
+ },
+ "base_class_from": {
+ "title": "Base class from field",
+ "title*": {
+ "de": "Basisklasse aus Feld"
+ },
+ "description": "Two-level narrowing. Path of the field that holds the base class, in the form ., for example slot.slot_type_restriction. Use this instead of \"base class\" when the base class is chosen elsewhere in the form rather than fixed here.",
+ "description*": {
+ "de": "Zweistufige Einschränkung. Pfad des Feldes, das die Basisklasse enthält, in der Form .. Statt \"Basisklasse\" verwenden, wenn die Basisklasse an anderer Stelle im Formular gewählt wird."
+ },
+ "type": "string",
+ "pattern": "^[a-z_][a-z0-9_]*\\.[a-z_][a-z0-9_]*$"
+ },
+ "instance_range": {
+ "title": "Instance value range",
+ "title*": {
+ "de": "Wertebereich der Instanz-Auswahl"
+ },
+ "description": "Class the instance picker draws from. Defaults to the default subclass, then to the base class. Required with \"base class from field\", because the base class is then only known at runtime.",
+ "description*": {
+ "de": "Klasse, aus der die Instanz-Auswahl schöpft. Standard ist die vorausgewählte Unterklasse, sonst die Basisklasse."
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:Category"
+ },
+ "omit_uuid": {
+ "title": "No uuid subfield",
+ "title*": {
+ "de": "Kein uuid-Unterfeld"
+ },
+ "description": "Leave out the generated uuid subfield. Set it only where the existing property has none, so that stored entries stay valid.",
+ "description*": {
+ "de": "Lässt das erzeugte uuid-Unterfeld weg. Nur setzen, wenn das vorhandene Attribut keines hat."
+ },
+ "type": "boolean",
+ "format": "checkbox",
+ "default": false
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/category_template_selection/meta/CharacteristicType.schema_template.txt b/tests/fixtures/category_template_selection/meta/CharacteristicType.schema_template.txt
new file mode 100644
index 0000000..b434c53
--- /dev/null
+++ b/tests/fixtures/category_template_selection/meta/CharacteristicType.schema_template.txt
@@ -0,0 +1,143 @@
+{
+ "@context": [
+ {{#each subclass_of}}
+ "/wiki/{{{.}}}?action=raw\u0026slot=jsonschema"{{#unless @last}},{{/unless}}
+ {{/each}}
+ {{#each characteristics}},
+ "/wiki/{{{.}}}?action=raw\u0026slot=jsonschema"
+ {{/each}}{{#if subclass_of}},{{else}}{{#if characteristics}},{{/if}}{{/if}}{
+ {{#join properties}}
+ {{#if instance_property}}"{{name}}": {"@id": "Property:HasStatement", "@type": "@id", "@context": {
+ "{{#if subfield_prefix}}{{{subfield_prefix}}}{{else}}{{{name}}}{{/if}}_instance_property": "Property:HasProperty",
+ "{{#if subfield_prefix}}{{{subfield_prefix}}}{{else}}{{{name}}}{{/if}}_instance": "Property:HasObject"
+ }}{{else}}{{#if rdf_property}}"{{name}}": "{{rdf_property}}"{{/if}}{{/if}}
+ {{/join}}
+ }
+ ],
+ "allOf": [
+ {{#each subclass_of}}
+ {"$ref": "/wiki/{{{.}}}?action=raw\u0026slot=jsonschema" }{{#unless @last}},{{/unless}}
+ {{/each}}
+ {{#each characteristics}},
+ {"$ref": "/wiki/{{{.}}}?action=raw\u0026slot=jsonschema"}
+ {{/each}}
+ ],
+ "type": "object",
+ "uuid": "{{{uuid}}}",
+ "title": "{{{name}}}",
+ "title*": {
+ {{#each label}}
+ "{{{lang}}}": "{{{text}}}"{{#unless @last}},{{/unless}}
+ {{/each}}
+ },
+ "description": "{{{description.[0].text}}}",
+ "description*": {
+ {{#each description}}
+ "{{{lang}}}": "{{{text}}}"{{#unless @last}},{{/unless}}
+ {{/each}}
+ },
+ "required": [
+ "type"
+ {{#each properties}}
+ {{#if required}},"{{name}}"{{/if}}
+ {{/each}}
+ ],
+ "defaultProperties": [
+ "type"
+ {{#each properties}}
+ {{#if recommended}},"{{name}}"{{/if}}
+ {{/each}}
+ ],
+ "properties": {
+ "type": {
+ "default": ["{{{_page_title}}}"]
+ }
+ {{#each properties}},
+ "{{{name}}}": {
+ {{#if instance_property}}
+ {{#if array}}"type": "array", "format": "tabs", "items": {
+ {{/if}}"type": "object",
+ "title": "{{{name}}}"
+ {{#if label}}, "title*": {
+ {{#each label}}
+ "{{{lang}}}": "{{{text}}}"{{#unless @last}},{{/unless}}
+ {{/each}}
+ }{{/if}}
+ {{#if description}}, "description": "{{{description.[0].text}}}",
+ "description*": {
+ {{#each description}}
+ "{{{lang}}}": "{{{text}}}"{{#unless @last}},{{/unless}}
+ {{/each}}
+ }{{/if}},
+ "id": "{{{name}}}",
+ "required": [{{#unless omit_uuid}}"uuid", {{/unless}}"{{#if subfield_prefix}}{{{subfield_prefix}}}{{else}}{{{name}}}{{/if}}_instance_property", "{{#if subfield_prefix}}{{{subfield_prefix}}}{{else}}{{{name}}}{{/if}}_instance"],
+ "properties": {
+ {{#unless omit_uuid}}"uuid": {
+ "title": "UUID", "type": "string", "format": "uuid",
+ "dynamic_template": "{{{{raw}}}}{{_uuid_}}{{{{/raw}}}}",
+ "options": {"hidden": true}
+ },{{/unless}}
+ {{#if context_label}}
+ "label": {"title": "Label", "type": "array", "format": "table",
+ "items": {"$ref": "/wiki/JsonSchema:Label?action=raw"}},
+ "description": {"title": "Description", "type": "array", "format": "table",
+ "items": {"$ref": "/wiki/JsonSchema:Description?action=raw"}},
+ {{/if}}
+ "{{#if subfield_prefix}}{{{subfield_prefix}}}{{else}}{{{name}}}{{/if}}_type": {
+ "title": "Type", "type": "string", "format": "autocomplete",
+ {{#if base_class_from}}"range": "Category:Category",
+ "watch": {"{{{name}}}_base_": "{{{base_class_from}}}"},
+ "options": {"autocomplete": {"query": "[[:$({{{name}}}_base_)]]OR[[SubClassOf::$({{{name}}}_base_)]]OR[[SubClassOf.SubClassOf::$({{{name}}}_base_)]]OR[[SubClassOf.SubClassOf.SubClassOf::$({{{name}}}_base_)]]OR[[SubClassOf.SubClassOf.SubClassOf.SubClassOf::$({{{name}}}_base_)]]OR[[SubClassOf.SubClassOf.SubClassOf.SubClassOf.SubClassOf::$({{{name}}}_base_)]]"}}
+ {{else}}"subclassof_range": "{{{base_class}}}"
+ {{/if}}
+ {{#if default_type}}, "default": "{{{default_type}}}"{{/if}}
+ },
+ "{{#if subfield_prefix}}{{{subfield_prefix}}}{{else}}{{{name}}}{{/if}}_instance_property": {
+ "type": "string", "default": "{{{instance_property}}}",
+ "options": {"hidden": true}
+ },
+ "{{#if subfield_prefix}}{{{subfield_prefix}}}{{else}}{{{name}}}{{/if}}_instance": {
+ "title": "Instance", "type": "string", "format": "autocomplete",
+ "range": "{{#if instance_range}}{{{instance_range}}}{{else}}{{#if default_type}}{{{default_type}}}{{else}}{{{base_class}}}{{/if}}{{/if}}",
+ "watch": {"{{{name}}}_type_": "{{{name}}}.{{#if subfield_prefix}}{{{subfield_prefix}}}{{else}}{{{name}}}{{/if}}_type"},
+ "options": {"autocomplete": {"query": "[[$({{{name}}}_type_)]]|?Display_title_of=label|?HasImage=image|?HasDescription=description"}}
+ }
+ {{#each extra_subfields}},
+ "{{{name}}}": {{#if array}}{"type": "array", "items": {{/if}}{"title": "{{#if title}}{{{title}}}{{else}}{{{name}}}{{/if}}", "type": "{{{type}}}"}{{#if array}} }{{/if}}
+ {{/each}}
+ }
+ {{#if array}} } {{/if}}
+ {{else}}
+ {{#if array}}"type": "array", "items": {
+ {{/if}}
+ "title": "{{{name}}}"
+ {{#if label}}, "title*": {
+ {{#each label}}
+ "{{{lang}}}": "{{{text}}}"{{#unless @last}},{{/unless}}
+ {{/each}}
+ }{{/if}}
+ {{#if description}}, "description": "{{{description.[0].text}}}",
+ "description*": {
+ {{#each description}}
+ "{{{lang}}}": "{{{text}}}"{{#unless @last}},{{/unless}}
+ {{/each}}
+ }{{/if}}
+ {{#if characteristic}}{{#if default_unit}}, "allOf": [{"$ref": "/wiki/{{{characteristic}}}?action=raw\u0026slot=jsonschema"}], "properties": {"unit": {"default": "{{{default_unit}}}"}}{{else}}, "$ref": "/wiki/{{{characteristic}}}?action=raw\u0026slot=jsonschema"{{/if}}{{else}}{{#if rdf_property}}, "$ref": "/wiki/{{{rdf_property}}}?action=raw\u0026slot=jsonschema"{{/if}}{{/if}}
+ {{#if range}}, "type": "string", "format": "autocomplete", "range": "{{{range}}}"
+ {{else}}{{#if type}}, "type": "{{{type}}}"{{/if}}
+ {{#if format}}, "format": "{{{format}}}"{{/if}}
+ {{/if}}
+ {{#if pattern}}, "pattern": "{{{pattern}}}"{{/if}}
+ {{#if minimum}}, "minimum": {{minimum}}{{/if}}
+ {{#if maximum}}, "maximum": {{maximum}}{{/if}}
+ {{#if exclusive_minimum}}, "exclusiveMinimum": {{exclusive_minimum}}{{/if}}
+ {{#if exclusive_maximum}}, "exclusiveMaximum": {{exclusive_maximum}}{{/if}}
+ {{#if multiple_of}}, "multipleOf": {{multiple_of}}{{/if}}
+ {{#if min_length}}, "minLength": {{min_length}}{{/if}}
+ {{#if max_length}}, "maxLength": {{max_length}}{{/if}}
+ {{#if array}} } {{/if}}
+ {{/if}}
+ }
+ {{/each}}
+ }
+}
diff --git a/tests/fixtures/category_template_selection/meta/Entity.jsondata.json b/tests/fixtures/category_template_selection/meta/Entity.jsondata.json
new file mode 100644
index 0000000..d9e62a2
--- /dev/null
+++ b/tests/fixtures/category_template_selection/meta/Entity.jsondata.json
@@ -0,0 +1,27 @@
+{
+ "uuid": "ce353767-c628-45bd-9d88-d6eb3009aec0",
+ "label": [
+ {
+ "text": "Entity",
+ "lang": "en"
+ },
+ {
+ "text": "Entität",
+ "lang": "de"
+ }
+ ],
+ "name": "Entity",
+ "description": [
+ {
+ "text": "This is meant to be the most basic category. Contrary to its subcategories, it is not meant to be instantiated",
+ "lang": "en"
+ },
+ {
+ "text": "Dies soll die aller grundlegendste Kategorie sein. Im Gegensatz zu ihren Subkategorien, ist diese nicht dazu gedacht instanziiert zu werden.",
+ "lang": "de"
+ }
+ ],
+ "type": [
+ "Category:Category"
+ ]
+}
diff --git a/tests/fixtures/category_template_selection/meta/Entity.jsonschema.json b/tests/fixtures/category_template_selection/meta/Entity.jsonschema.json
new file mode 100644
index 0000000..8660918
--- /dev/null
+++ b/tests/fixtures/category_template_selection/meta/Entity.jsonschema.json
@@ -0,0 +1,728 @@
+{
+ "@context": {
+ "owl": "http://www.w3.org/2002/07/owl#",
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
+ "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
+ "schema": "https://schema.org/",
+ "skos": "https://www.w3.org/TR/skos-reference/",
+ "wiki": "https://wiki-dev.open-semantic-lab.org/id/",
+ "xsd": "http://www.w3.org/2001/XMLSchema#",
+ "odrl": "http://www.w3.org/ns/odrl/2/",
+ "Category": {
+ "@id": "wiki:Category-3A",
+ "@prefix": true
+ },
+ "File": {
+ "@id": "https://wiki-dev.open-semantic-lab.org/wiki/Special:Redirect/file/",
+ "@prefix": true
+ },
+ "Item": {
+ "@id": "wiki:Item-3A",
+ "@prefix": true
+ },
+ "Property": {
+ "@id": "wiki:Property-3A",
+ "@prefix": true
+ },
+ "attachments*": {
+ "@id": "Property:HasFileAttachment",
+ "@type": "@id"
+ },
+ "based_on": {
+ "@id": "skos:isBasedOn",
+ "@type": "@id"
+ },
+ "based_on*": {
+ "@id": "Property:IsBasedOn",
+ "@type": "@id"
+ },
+ "close_ontology_match": {
+ "@id": "skos:closeMatch",
+ "@type": "@id"
+ },
+ "close_ontology_match*": {
+ "@id": "Property:Equivalent_URI",
+ "@type": "@id"
+ },
+ "close_ontology_match**": {
+ "@id": "Property:HasCloseOntologyMatch",
+ "@type": "@id"
+ },
+ "description": {
+ "@id": "skos:definition"
+ },
+ "description*": {
+ "@id": "Property:HasDescription"
+ },
+ "exact_ontology_match": {
+ "@id": "skos:exactMatch",
+ "@type": "@id"
+ },
+ "exact_ontology_match*": {
+ "@id": "Property:Equivalent_URI",
+ "@type": "@id"
+ },
+ "exact_ontology_match**": {
+ "@id": "Property:HasExactOntologyMatch",
+ "@type": "@id"
+ },
+ "image": {
+ "@id": "schema:image",
+ "@type": "@id"
+ },
+ "image*": {
+ "@id": "Property:HasImage",
+ "@type": "@id"
+ },
+ "keywords": {
+ "@id": "schema:keywords",
+ "@type": "@id"
+ },
+ "keywords*": {
+ "@id": "Property:HasKeyword",
+ "@type": "@id"
+ },
+ "label": {
+ "@id": "skos:prefLabel"
+ },
+ "label*": {
+ "@id": "Property:HasLabel"
+ },
+ "lang": {
+ "@id": "@language"
+ },
+ "meta": {
+ "@id": "Property:HasMeta",
+ "@type": "@id",
+ "@context": {
+ "change_id": {
+ "@id": "Property:HasChangeId",
+ "@type": "xsd:string"
+ }
+ }
+ },
+ "name*": {
+ "@id": "Property:HasName"
+ },
+ "classification_categories": {
+ "@id": "Property:Category",
+ "@type": "@id"
+ },
+ "classification_categories*": {
+ "@id": "Property:HasClassificationCategory",
+ "@type": "@id"
+ },
+ "query_label": {
+ "@id": "Property:HasLabel",
+ "@type": "@id"
+ },
+ "rdf_type": {
+ "@id": "@type",
+ "@type": "@id"
+ },
+ "short_name": {
+ "@id": "Property:HasShortName"
+ },
+ "access_policy": {
+ "@id": "odrl:hasPolicy",
+ "@type": "@id",
+ "@context": {
+ "usergroup_permissions": {
+ "@id": "odrl:permission",
+ "@type": "@id",
+ "@context": {
+ "assignee_usergroup": {
+ "@id": "odrl:assignee",
+ "@type": "@id"
+ },
+ "action": {
+ "@id": "odrl:action",
+ "@type": "@id"
+ }
+ }
+ },
+ "user_permissions": {
+ "@id": "odrl:permission",
+ "@type": "@id",
+ "@context": {
+ "assignee_user": {
+ "@id": "odrl:assignee",
+ "@type": "@id"
+ },
+ "action": {
+ "@id": "odrl:action",
+ "@type": "@id"
+ }
+ }
+ }
+ }
+ },
+ "statements": {
+ "@id": "Property:HasStatement",
+ "@type": "@id"
+ },
+ "text": {
+ "@id": "@value"
+ },
+ "type": {
+ "@id": "Property:HasType",
+ "@type": "@id"
+ },
+ "type*": {
+ "@id": "Property:HasSchema",
+ "@type": "@id"
+ },
+ "uuid*": {
+ "@id": "Property:HasUuid"
+ }
+ },
+ "uuid": "ce353767-c628-45bd-9d88-d6eb3009aec0",
+ "title": "Entity",
+ "type": "object",
+ "id": "entity",
+ "required": [
+ "uuid",
+ "label"
+ ],
+ "defaultProperties": [
+ "description"
+ ],
+ "properties": {
+ "type": {
+ "title": "Types/Categories",
+ "title*": {
+ "de": "Typen/Kategorien"
+ },
+ "propertyOrder": -1010,
+ "type": "array",
+ "format": "table",
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}} <%#type%> {{Viewer/Link |page=<%.%> }}
<%/type%>"
+ }
+ ],
+ "items": {
+ "title": "Type",
+ "title*": {
+ "de": "Typ"
+ },
+ "type": "string"
+ },
+ "default": [
+ "Category:Entity"
+ ],
+ "minItems": 1
+ },
+ "rdf_type": {
+ "title": "Additional RDF type(s)",
+ "title*": {
+ "de": "Zusätzliche(r) RDF-Typ(en)"
+ },
+ "description": "Declares additional type(s) for this entity, thereby stating that this entity is an instance of an additional RDF class. See also https://schema.org/additionalType. Category:Entity is not directly instantiated. Therefor the inheriting classes (Category:Category, Category:Item, Category:Property) decide on the implementation and mapping within the \"@context\". The value must be a full IRI, e.g. https://schema.org/Person",
+ "description*": {
+ "de": "Deklariert zusätzliche(n) Typ(en) für diese Entität und gibt damit an, dass diese Entität eine Instanz einer zusätzlichen RDF-Klasse ist. Siehe auch https://schema.org/additionalType. Category:Entity wird nicht direkt instanziiert. Daher entscheiden die erbenden Klassen (Category:Category, Category:Item, Category:Property) über die Implementierung und das Mapping innerhalb des \"@context\". Der Wert muss eine vollständige IRI sein, z.B. https://schema.org/Person"
+ },
+ "type": "array",
+ "format": "table",
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{#rdf_type}} {{=<% %>=}} {{Viewer/Link |url= <%={{ }}=%> {{{.}}} {{=<% %>=}} }}
<%={{ }}=%>{{/rdf_type}}"
+ }
+ ],
+ "items": {
+ "title": "RDF type",
+ "title*": {
+ "de": "RDF-Typ"
+ },
+ "type": "string"
+ },
+ "uniqueItems": true
+ },
+ "exact_ontology_match": {
+ "type": "array",
+ "title": "Ontology Match (Exact)",
+ "description": "Only use this property if all properties defined in the Ontology can be asserted to this object.",
+ "titel*": {
+ "de": "Ontologieübereinstimmung (exakt)"
+ },
+ "description*": {
+ "de": "Verwenden Sie diese Eigenschaft nur, wenn alle in der Ontologie definierten Eigenschaften auf dieses Objekt übertragen werden können."
+ },
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{#exact_ontology_match}} {{=<% %>=}} {{Viewer/Link |url= <%={{ }}=%> {{{.}}} {{=<% %>=}} }}
<%={{ }}=%>{{/exact_ontology_match}}"
+ }
+ ],
+ "items": {
+ "type": "string",
+ "options": {
+ "compact": "true"
+ }
+ },
+ "uniqueItems": true
+ },
+ "close_ontology_match": {
+ "type": "array",
+ "title": "Ontology Match (Close)",
+ "description": "Use this property if you are not sure if all properties from the ontology can be asserted to this object but the meaning is very close.",
+ "titel*": {
+ "de": "Ontologieübereinstimmung (ähnlich)"
+ },
+ "description*": {
+ "de": "Verwenden Sie diese Eigenschaft, wenn Sie nicht sicher sind, ob alle Eigenschaften aus der Ontologie für dieses Objekt geltend gemacht werden können, die Bedeutung aber sehr nahe liegt."
+ },
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{#close_ontology_match}} {{=<% %>=}} {{Viewer/Link |url= <%={{ }}=%> {{{.}}} {{=<% %>=}} }}
<%={{ }}=%>{{/close_ontology_match}}"
+ }
+ ],
+ "items": {
+ "type": "string",
+ "options": {
+ "compact": "true"
+ }
+ },
+ "uniqueItems": true
+ },
+ "uuid": {
+ "title": "UUID",
+ "type": "string",
+ "format": "uuid",
+ "options": {
+ "hidden": true
+ }
+ },
+ "iri": {
+ "title": "IRI",
+ "description": "The Internationalized Resource Identifier (IRI) of this entity",
+ "description*": {
+ "de": "Der internationalisierte Ressourcenbezeichner (IRI) dieser Entität"
+ },
+ "type": "string",
+ "format": "url",
+ "options": {
+ "hidden": true
+ }
+ },
+ "name": {
+ "title": "Technical name",
+ "title*": {
+ "de": "Technischer Name"
+ },
+ "description": "Technical / Machine compatible name",
+ "description*": {
+ "de": "Technischer / Maschinenkompatibler Name"
+ },
+ "type": "string",
+ "options": {
+ "hidden": true
+ }
+ },
+ "label": {
+ "type": "array",
+ "title": "Label(s)",
+ "title*": {
+ "de": "Bezeichnung(en)"
+ },
+ "description": "At least one label is required.",
+ "description*": {
+ "de": "Mindestens eine Bezeichnung ist erforderlich."
+ },
+ "format": "table",
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "$comment": "Displays value according to user language with eng as fallback option. Note: {{=<% %>=}} changes mustache expression from {{..}} to <% %> for mixing with wikitext templates",
+ "value": "{{=<% %>=}} {{#switch:{{USERLANGUAGECODE}} <%#label%> | {{#ifeq: <%lang%>|en|#default|<%lang%>}} = <%text%> <%/label%> }}"
+ }
+ ],
+ "items": {
+ "$ref": "/wiki/JsonSchema:Label?action=raw"
+ },
+ "minItems": 1
+ },
+ "short_name": {
+ "type": "array",
+ "title": "Short name(s)",
+ "title*": {
+ "de": "Kurzname(n)"
+ },
+ "description": "Abbreviation, Acronym, etc.",
+ "description*": {
+ "de": "Abkürzung, Akronym, etc."
+ },
+ "format": "table",
+ "eval_template": {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "$comment": "Displays value according to user language with eng as fallback option. Note: {{=<% %>=}} changes mustache expression from {{..}} to <% %> for mixing with wikitext templates",
+ "value": "{{=<% %>=}} {{#switch:{{USERLANGUAGECODE}} <%#short_name%> | {{#ifeq: <%lang%>|en|#default|<%lang%>}} = <%text%> <%/short_name%> }}"
+ },
+ "items": {
+ "title": "ShortName",
+ "title*": {
+ "en": "Short name",
+ "de": "Kurzname"
+ },
+ "$ref": "/wiki/JsonSchema:Label?action=raw"
+ }
+ },
+ "query_label": {
+ "title": "Query label",
+ "title*": {
+ "de": "Abfrage-Bezeichnung"
+ },
+ "type": "string",
+ "options": {
+ "hidden": true,
+ "conditional_visible": {
+ "modes": [
+ "query"
+ ]
+ }
+ }
+ },
+ "description": {
+ "title": "Description",
+ "title*": {
+ "de": "Beschreibung"
+ },
+ "type": "array",
+ "format": "table",
+ "eval_template": {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "$comment": "Displays value according to user language with eng as fallback option. Note: {{=<% %>=}} changes mustache expression from {{..}} to <% %> for mixing with wikitext templates",
+ "value": "{{=<% %>=}} {{#switch:{{USERLANGUAGECODE}} <%#description%> | {{#ifeq: <%lang%>|en|#default|<%lang%>}} = <%text%> <%/description%> }}"
+ },
+ "items": {
+ "$ref": "/wiki/JsonSchema:Description?action=raw"
+ }
+ },
+ "image": {
+ "title": "Image",
+ "title*": {
+ "de": "Bild"
+ },
+ "propertyOrder": 1020,
+ "type": "string",
+ "format": "url",
+ "options": {
+ "upload": {}
+ },
+ "links": [
+ {
+ "href": "{{#if self}}/w/index.php?title=Special:Redirect/file/{{self}}&width=200&height=200{{/if}}",
+ "mediaType": "image"
+ },
+ {
+ "href": "{{#if self}}/w/index.php?title=Special:Redirect/file/{{self}}{{/if}}",
+ "rel": "{{#if self}}download{{/if}}",
+ "download": true
+ }
+ ]
+ },
+ "classification_categories": {
+ "type": "array",
+ "format": "table",
+ "title": "Classification categories",
+ "title*": {
+ "de": "Ordnungskategorien"
+ },
+ "description": "Classification categories are used to categorize instances, e.g., according to their use but not their properties. When querying for instances of a here listed classification category, this instance will be returned. Note: Classification categories define no properties, while 'regular' categories define properties, which an instance assigns values to.",
+ "description*": {
+ "de": "Ordnungskategorien werden verwendet, um Instanzen zu kategorisieren, z. B. nach ihrer Verwendung, nicht aber nach ihren Eigenschaften. Bei der Abfrage nach Instanzen einer hier aufgeführten Ordnungskategorie wird diese Instanz zurückgegeben. Hinweis: Ordnungskategorien definieren keine Eigenschaften, während 'normale' Kategorien Eigenschaften definieren, denen eine Instanz Werte zuweist."
+ },
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}} <%#classification_categories%> {{Viewer/Link |page=<%.%> }}
<%/classification_categories%>"
+ }
+ ],
+ "items": {
+ "title": "Classification category",
+ "title*": {
+ "de": "Ordnungskategorie"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "options": {
+ "autocomplete": {
+ "$comment": "SubCategories of Item OR OwlThing (Category:OSW379d5a1589c74c82bc0de47938264d00)",
+ "query": "[[SubClassOf::Category:Item]] OR [[SubClassOf.SubClassOf::Category:Item]] OR [[SubClassOf.SubClassOf.SubClassOf::Category:Item]] OR [[SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:Item]] OR [[SubClassOf.SubClassOf.SubClassOf.SubClassOf.SubClassOf::Category:Item]] OR [[SubClassOf::Category:OSW379d5a1589c74c82bc0de47938264d00]]"
+ }
+ }
+ }
+ },
+ "keywords": {
+ "type": "array",
+ "format": "table",
+ "title": "Keywords / Tags",
+ "title*": {
+ "de": "Schlagworte / Tags"
+ },
+ "description": "Designated to the user defined categorization of this element",
+ "description*": {
+ "de": "Dient der nutzerdefinierten Kategorisierung des Elements"
+ },
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}} <%#keywords%> {{Viewer/Link |page=<%.%> }}
<%/keywords%>"
+ }
+ ],
+ "items": {
+ "title": "Keyword",
+ "title*": {
+ "de": "Schlagwort"
+ },
+ "type": "string",
+ "format": "autocomplete",
+ "range": "Category:OSW09f6cdd54bc54de786eafced5f675cbe"
+ }
+ },
+ "based_on": {
+ "type": "array",
+ "title": "Based on",
+ "title*": {
+ "de": "Basierend auf"
+ },
+ "description": "Other entities on which this one is based, e.g. when it is created by copying",
+ "description*": {
+ "de": "Andere Entitäten auf die diese basiert, z. B. wenn sie durch Kopieren entstanden ist"
+ },
+ "eval_template": [
+ {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}} <%#based_on%> {{Viewer/Link |page=<%.%> }}
<%/based_on%>"
+ }
+ ],
+ "items": {
+ "type": "string",
+ "format": "autocomplete",
+ "options": {
+ "autocomplete": {
+ "$comment": "All categories here since subclassof_range is limited by depth 5",
+ "query": "[[:Category:%2B]]"
+ }
+ }
+ },
+ "options": {
+ "hidden": "true"
+ }
+ },
+ "access_policy": {
+ "title": "AccessAndUsagePolicy",
+ "title*": {
+ "en": "Access policy",
+ "de": "Zugriffsbeschränkungen"
+ },
+ "description": "Defines access control rules (who can read/write this entity).",
+ "description*": {
+ "de": "Definiert Zugriffsregeln (wer darf diese Entität lesen/bearbeiten)."
+ },
+ "type": "object",
+ "defaultProperties": [
+ "usergroup_permissions"
+ ],
+ "$comment": "See also https://www.w3.org/TR/odrl-vocab/#term-hasPolicy and https://w3c.github.io/odrl/bp/#. For interoperability with DSP/DCAT see https://docs.internationaldataspaces.org/ids-knowledgebase/dataspace-protocol/catalog/catalog.protocol#id-1.2-dcat-and-odrl-profiles.",
+ "eval_template": {
+ "$comment": "See https://www.mediawiki.org/wiki/Extension:Semantic_ACL",
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}} <%#access_policy.usergroup_permissions%> {{#if: <%assignee_usergroup%> | [[<%assignee_usergroup%>]] {{#switch: <%action%> |odrl:read= {{#set: |Visible to=whitelist |Visible to group= {{#ask: [[<%assignee_usergroup%>]]|?HasGroupname=|mainlabel=-|link=none|format=plain}} }} (read) |odrl:write= {{#set: |Editable by=whitelist |Editable by group= {{#ask: [[<%assignee_usergroup%>]]|?HasGroupname=|mainlabel=-|link=none|format=plain}} }} (write) |#default=}}
|}} <%/access_policy.usergroup_permissions%> <%#access_policy.user_permissions%> {{#if: <%assignee_user%> | [[<%assignee_user%>]] {{#switch: <%action%> |odrl:read= {{#set: |Visible to=whitelist |Visible to user= User:{{#ask: [[<%assignee_user%>]]|?HasUsername=|mainlabel=-|link=none|format=plain}} }} (read) |odrl:write= {{#set: |Editable by=whitelist |Editable by user= User:{{#ask: [[<%assignee_user%>]]|?HasUsername=|mainlabel=-|link=none|format=plain}} }} (write) |#default=}}
|}} <%/access_policy.user_permissions%>"
+ },
+ "properties": {
+ "usergroup_permissions": {
+ "title": "GroupPermissions",
+ "title*": {
+ "en": "Group permissions",
+ "de": "Gruppen-Erlaubnisse"
+ },
+ "type": "array",
+ "format": "table",
+ "items": {
+ "title": "GroupPermission",
+ "title*": {
+ "en": "Permission",
+ "de": "Erlaubnis"
+ },
+ "type": "object",
+ "required": [
+ "assignee_usergroup",
+ "action"
+ ],
+ "properties": {
+ "assignee_usergroup": {
+ "title": "User group",
+ "title*": {
+ "de": "Nutzergruppe"
+ },
+ "type": "string",
+ "minLength": 1,
+ "format": "autocomplete",
+ "range": "Category:OSWb24f37ada8d043c194e7036df5d86b39",
+ "options": {
+ "autocomplete": {
+ "$commment": "user should not create new or edit existing groups here",
+ "create_inline": false
+ }
+ }
+ },
+ "action": {
+ "type": "string",
+ "title": "Action",
+ "title*": {
+ "de": "Aktion"
+ },
+ "enum": [
+ "odrl:read",
+ "odrl:write"
+ ],
+ "options": {
+ "enum_titles": [
+ "read",
+ "edit"
+ ],
+ "enum_titles*": [
+ "Lesen",
+ "Bearbeiten"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "user_permissions": {
+ "title": "UserPermissions",
+ "title*": {
+ "en": "User permissions",
+ "de": "Nutzer-Erlaubnisse"
+ },
+ "type": "array",
+ "format": "table",
+ "items": {
+ "title": "Permission",
+ "title*": {
+ "en": "Permission",
+ "de": "Erlaubnis"
+ },
+ "type": "object",
+ "required": [
+ "assignee_user",
+ "action"
+ ],
+ "properties": {
+ "assignee_user": {
+ "title": "Specific user",
+ "title*": {
+ "de": "Bestimmter Nutzer"
+ },
+ "type": "string",
+ "minLength": 1,
+ "format": "autocomplete",
+ "range": "Category:OSW8b3e627eb76b46d68fce3a12ff220c8e",
+ "options": {
+ "autocomplete": {
+ "$commment": "user should not create new or edit existing accounts here",
+ "create_inline": false
+ }
+ }
+ },
+ "action": {
+ "type": "string",
+ "title": "Action",
+ "title*": {
+ "de": "Aktion"
+ },
+ "enum": [
+ "odrl:read",
+ "odrl:write"
+ ],
+ "options": {
+ "enum_titles": [
+ "read",
+ "edit"
+ ],
+ "enum_titles*": [
+ "Lesen",
+ "Bearbeiten"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "statements": {
+ "type": "array",
+ "title": "Statements",
+ "title*": {
+ "de": "Aussagen"
+ },
+ "format": "tabs",
+ "propertyOrder": 1010,
+ "eval_template": {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}} <%#statements%>.. {{Viewer/Link |page=<%#predicate%><%predicate%><%/predicate%><%#property%><%property%><%/property%><%#quantity%><%quantity%><%/quantity%>}} <%#object%>{{Viewer/Link |page=<%object%>}}<%/object%><%#value%><%value%><%/value%>
<%/statements%>"
+ },
+ "items": {
+ "$ref": "/wiki/JsonSchema:Statement?action=raw"
+ }
+ },
+ "attachments": {
+ "type": "array",
+ "title": "File attachments",
+ "title*": {
+ "de": "Dateianhänge"
+ },
+ "eval_template": {
+ "type": "mustache-wikitext",
+ "mode": "render",
+ "value": "{{=<% %>=}} <%={{ }}=%> {{#attachments}}{{{.}}};{{/attachments}} {{=<% %>=}} <%={{ }}=%>"
+ },
+ "options": {
+ "collapsed": true
+ },
+ "propertyOrder": 1030,
+ "items": {
+ "title": "Attachment",
+ "title*": {
+ "de": "Anhang"
+ },
+ "type": "string",
+ "format": "url",
+ "options": {
+ "upload": {}
+ },
+ "links": [
+ {
+ "href": "{{#if self}}/w/index.php?title=Special:Redirect/file/{{self}}{{/if}}",
+ "rel": "{{#if self}}download{{/if}}",
+ "download": true
+ }
+ ]
+ }
+ },
+ "meta": {
+ "$ref": "/wiki/JsonSchema:Meta?action=raw",
+ "options": {
+ "hidden": true
+ }
+ }
+ }
+}
diff --git a/tests/test_store_entity_category_templates.py b/tests/test_store_entity_category_templates.py
new file mode 100644
index 0000000..b23f2bb
--- /dev/null
+++ b/tests/test_store_entity_category_templates.py
@@ -0,0 +1,204 @@
+"""store_entity() must select category templates like the wiki form editor.
+
+The fixtures are two real Category pages (PhysicalItem and PhysicalItemType) in
+the slot state before and after an unchanged save through the form editor of the
+dev wiki on 2026-11-05 (revisions 134604 and 134605). The form editor only added
+"version": "0.1.0" to each jsondata.
+
+The test stores the after-jsondata offline on top of the before-jsonschema and
+expects the jsonschema the form editor wrote. The meta category pages come from
+the package files in tests/fixtures/category_template_selection/meta, so no wiki
+is read or written.
+"""
+
+import copy
+import json
+from pathlib import Path
+
+import pytest
+from opensemantic.core.v1 import Category
+
+from osw.core import (
+ OSW,
+ collect_category_template_titles,
+ reset_generated_jsonld_context,
+ strip_root_members_superseded_by_generated,
+)
+from osw.wtsite import WtPage
+
+FIXTURES = Path(__file__).parent / "fixtures" / "category_template_selection"
+CHARACTERISTIC_TYPE = "Category:OSWffe74f291d354037b318c422591c5023"
+
+# fixture file stem -> page title
+META_PAGES = {
+ "Category:Category": "Category",
+ CHARACTERISTIC_TYPE: "CharacteristicType",
+ "Category:Entity": "Entity",
+}
+
+PAGES = {
+ "PhysicalItem": "Category:OSW37a57741ae2e4dd4b29b1172b6848be8",
+ "PhysicalItemType": "Category:OSWca99a9bb9ad24386b222d18a73c2af5e",
+}
+
+
+def _load(name):
+ return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
+
+
+class _FakeMetaCategoryPage:
+ def __init__(self, title, stem):
+ self.title = title
+ meta = FIXTURES / "meta"
+ template_file = meta / f"{stem}.schema_template.txt"
+ self._slots = {
+ "jsondata": json.loads(
+ (meta / f"{stem}.jsondata.json").read_text(encoding="utf-8")
+ ),
+ "jsonschema": json.loads(
+ (meta / f"{stem}.jsonschema.json").read_text(encoding="utf-8")
+ ),
+ "schema_template": (
+ template_file.read_text(encoding="utf-8")
+ if template_file.exists()
+ else None
+ ),
+ }
+
+ def get_slot_content(self, slot_key):
+ return self._slots.get(slot_key)
+
+
+class _FakeGetPageResult:
+ def __init__(self, pages):
+ self.pages = pages
+
+
+class _FakeSite:
+ """Serves the meta category pages from the fixture files."""
+
+ def __init__(self):
+ self.requested = []
+
+ def get_page(self, param):
+ self.requested.extend(param.titles)
+ return _FakeGetPageResult([
+ _FakeMetaCategoryPage(t, META_PAGES[t]) for t in param.titles
+ ])
+
+
+def _store_offline(monkeypatch, jsondata, stored_schema):
+ original_policy = OSW._apply_overwrite_policy
+
+ def policy_with_stored_schema(param):
+ param.page.set_slot_content("jsonschema", copy.deepcopy(stored_schema))
+ return original_policy(param)
+
+ monkeypatch.setattr(
+ OSW, "_apply_overwrite_policy", staticmethod(policy_with_stored_schema)
+ )
+ site = _FakeSite()
+ entity = Category(**copy.deepcopy(jsondata))
+ result = OSW.construct(site=site).store_entity(
+ OSW.StoreEntityParam(entities=[entity], namespace="Category", offline=True)
+ )
+ (page,) = result.pages.values()
+ assert isinstance(page, WtPage)
+ return page.get_slot_content("jsonschema"), site
+
+
+@pytest.mark.parametrize("name", sorted(PAGES))
+def test_store_matches_form_editor_save(monkeypatch, name):
+ title = PAGES[name]
+ before = _load("before-form-editor-save.json")[title]
+ after = _load("after-form-editor-save.json")[title]
+
+ schema, site = _store_offline(monkeypatch, after["jsondata"], before["jsonschema"])
+
+ assert schema == after["jsonschema"]
+ assert CHARACTERISTIC_TYPE in site.requested
+
+
+@pytest.mark.parametrize("name", sorted(PAGES))
+def test_generated_section_lists_both_templates(monkeypatch, name):
+ title = PAGES[name]
+ before = _load("before-form-editor-save.json")[title]
+ after = _load("after-form-editor-save.json")[title]
+
+ schema, _ = _store_offline(monkeypatch, after["jsondata"], before["jsonschema"])
+
+ comment = schema["$defs"]["generated"]["$comment"]
+ assert comment.endswith(f"Category:Category {CHARACTERISTIC_TYPE}")
+
+
+def test_collect_follows_allof_in_wiki_order():
+ schemas = {
+ "Category:Category": {
+ "uuid": "89aafe6d-ae5a-4f29-97ff-df7736d4cab6",
+ "allOf": [
+ {"$ref": f"/wiki/{CHARACTERISTIC_TYPE}?action=raw&slot=jsonschema"}
+ ],
+ },
+ CHARACTERISTIC_TYPE: {
+ "uuid": "ffe74f29-1d35-4037-b318-c422591c5023",
+ "allOf": [{"$ref": "/wiki/Category:Entity?action=raw&slot=jsonschema"}],
+ },
+ "Category:Entity": {"uuid": "ce353767-c628-45bd-9d88-d6eb3009aec0"},
+ }
+
+ class Page:
+ def __init__(self, title):
+ self.title = title
+
+ def get_slot_content(self, slot):
+ return schemas[self.title]
+
+ titles = collect_category_template_titles(
+ lambda ts: [Page(t) for t in ts], ["Category:Category"]
+ )
+
+ assert titles == ["Category:Category", CHARACTERISTIC_TYPE]
+
+
+def test_reset_context_keeps_manual_block_when_never_generated():
+ schema = {"@context": ["/wiki/Category:A", {"manual": "x"}]}
+
+ reset_generated_jsonld_context(schema, had_generated_section=False)
+
+ assert schema["@context"] == ["/wiki/Category:A", {}, {"manual": "x"}]
+
+
+def test_reset_context_empties_generated_block():
+ schema = {"@context": ["/wiki/Category:A", {"gen": "x"}, {"manual": "y"}]}
+
+ reset_generated_jsonld_context(schema, had_generated_section=True)
+
+ assert schema["@context"] == ["/wiki/Category:A", {}, {"manual": "y"}]
+
+
+def test_strip_keeps_manual_siblings():
+ root = {
+ "uuid": "u",
+ "title": "t",
+ "properties": {"type": {"default": ["x"], "minItems": 1}},
+ }
+ generated = {"uuid": "u", "properties": {"type": {"default": ["x"]}}}
+
+ assert strip_root_members_superseded_by_generated(root, generated) is True
+ assert root == {"title": "t", "properties": {"type": {"minItems": 1}}}
+
+
+def test_collect_warns_when_a_schema_is_missing(caplog):
+ class Page:
+ title = "Category:Category"
+
+ def get_slot_content(self, slot):
+ return None
+
+ with caplog.at_level("WARNING", logger="osw.core"):
+ titles = collect_category_template_titles(
+ lambda ts: [Page()], ["Category:Category"]
+ )
+
+ assert titles == ["Category:Category"]
+ assert "No jsonschema found for Category:Category" in caplog.text
diff --git a/tests/test_store_entity_keeps_jsonschema.py b/tests/test_store_entity_keeps_jsonschema.py
index 292a857..630c094 100644
--- a/tests/test_store_entity_keeps_jsonschema.py
+++ b/tests/test_store_entity_keeps_jsonschema.py
@@ -24,14 +24,20 @@
from osw.core import OSW
from osw.wtsite import WtPage
-META_CATEGORY = "Category:OSWMetaCategory"
+META_UUID = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
+META_CATEGORY = "Category:OSW0a1b2c3d4e5f4a6b8c7d9e0f1a2b3c4d"
+CHARACTERISTIC_TYPE = "Category:OSWffe74f291d354037b318c422591c5023"
GEN_POINTER = "#/$defs/generated"
class _FakeMetaCategoryPage:
- def __init__(self, title, template):
+ def __init__(self, title, template, schema):
self.title = title
- self._slots = {"schema_template": template, "jsondata": {}}
+ self._slots = {
+ "schema_template": template,
+ "jsondata": {},
+ "jsonschema": schema,
+ }
def get_slot_content(self, slot_key):
return self._slots[slot_key]
@@ -43,13 +49,33 @@ def __init__(self, pages):
class _FakeSite:
- def __init__(self, template):
+ """Serves a meta category with the template under test.
+
+ Like in the wiki, the templates follow the allOf chain of the meta category
+ schema, so the meta category refers to CharacteristicType when `definitions`
+ is set.
+ """
+
+ def __init__(self, template, definitions):
self.template = template
+ self.definitions = definitions
+
+ def _page(self, title):
+ if title == META_CATEGORY:
+ schema = {"uuid": META_UUID}
+ if self.definitions:
+ schema["allOf"] = [
+ {"$ref": f"/wiki/{CHARACTERISTIC_TYPE}?action=raw&slot=jsonschema"}
+ ]
+ return _FakeMetaCategoryPage(title, self.template, schema)
+ if title == CHARACTERISTIC_TYPE:
+ return _FakeMetaCategoryPage(
+ title, None, {"uuid": "ffe74f29-1d35-4037-b318-c422591c5023"}
+ )
+ return _FakeMetaCategoryPage(title, None, {})
def get_page(self, param):
- return _FakeGetPageResult([
- _FakeMetaCategoryPage(t, self.template) for t in param.titles
- ])
+ return _FakeGetPageResult([self._page(t) for t in param.titles])
@pytest.fixture
@@ -60,7 +86,11 @@ def store(monkeypatch):
"""
original_policy = OSW._apply_overwrite_policy
- def _store(entity, template, stored):
+ def _store(entity, template, stored, definitions=None):
+ if definitions is None:
+ # characteristics select the definitions section mode in the wiki
+ definitions = isinstance(entity, CharacteristicType)
+
def policy_with_stored_schema(param):
if stored is not None:
param.page.set_slot_content(
@@ -71,7 +101,7 @@ def policy_with_stored_schema(param):
monkeypatch.setattr(
OSW, "_apply_overwrite_policy", staticmethod(policy_with_stored_schema)
)
- osw_obj = OSW.construct(site=_FakeSite(template))
+ osw_obj = OSW.construct(site=_FakeSite(template, definitions))
result = osw_obj.store_entity(
OSW.StoreEntityParam(
entities=[entity],
From 55e500efb896f0a3d6bde98409b3a6f43a652bdc Mon Sep 17 00:00:00 2001
From: LukasGold <41064839+LukasGold@users.noreply.github.com>
Date: Thu, 5 Nov 2026 18:13:26 +0100
Subject: [PATCH 3/4] fix: follow properties and items when collecting category
templates (#220)
- walk properties and items after allOf, definitions and $defs
- a referenced schema is walked at its first occurrence only
- update the docstring
---
src/osw/core.py | 15 ++++--
tests/test_store_entity_category_templates.py | 49 +++++++++++++++++++
2 files changed, 60 insertions(+), 4 deletions(-)
diff --git a/src/osw/core.py b/src/osw/core.py
index c971d5c..dd16761 100644
--- a/src/osw/core.py
+++ b/src/osw/core.py
@@ -259,13 +259,15 @@ def collect_category_template_titles(
Mirrors the editor: the schema of the meta categories is bundled, the uuid of
every schema is collected after the schemas below it (allOf, oneOf, anyOf,
- definitions and $defs are followed in this order, so the most generic schema
- comes first) and the result starts with Category:Category.
+ definitions, $defs, every entry of properties and the items of each property
+ are followed in this order, so the most generic schema comes first) and the
+ result starts with Category:Category. A referenced schema is followed only
+ at its first occurrence, like in a bundled schema.
Category:Category and Category:Entity are not collected. Follows
osl.util.postProcessPage (PageBot.js) with editor.jsonschema.subschemas_uuids
(MwJson_schema.js, _preprocess) in mediawiki-extensions-OpenSemanticLab and
- mediawiki-extensions-MwJson. Schemas reached through properties are not
- followed.
+ mediawiki-extensions-MwJson. Only references to category pages are
+ followed, a reference to another page, e.g. JsonSchema:Label, is not.
Parameters
----------
@@ -310,6 +312,11 @@ def walk(schema: Any) -> None:
if isinstance(schema.get(key), dict):
for entry in schema[key].values():
walk(entry)
+ if isinstance(schema.get("properties"), dict):
+ for entry in schema["properties"].values():
+ walk(entry)
+ if isinstance(entry, dict):
+ walk(entry.get("items"))
uuid = schema.get("uuid")
if uuid and uuid not in (CATEGORY_CATEGORY_UUID, CATEGORY_ENTITY_UUID):
title = "Category:" + get_osw_id(uuid)
diff --git a/tests/test_store_entity_category_templates.py b/tests/test_store_entity_category_templates.py
index b23f2bb..a16ba6a 100644
--- a/tests/test_store_entity_category_templates.py
+++ b/tests/test_store_entity_category_templates.py
@@ -202,3 +202,52 @@ def get_slot_content(self, slot):
assert titles == ["Category:Category"]
assert "No jsonschema found for Category:Category" in caplog.text
+
+
+def test_collect_follows_properties_and_items_in_wiki_order():
+ meta = "Category:OSW0a1b2c3d4e5f4a6b8c7d9e0f1a2b3c4d"
+ prop_cat = "Category:OSW11111111222243338444555555555555"
+ items_cat = "Category:OSW66666666777748889999aaaaaaaaaaaa"
+
+ def ref(title):
+ return {"$ref": f"/wiki/{title}?action=raw&slot=jsonschema"}
+
+ schemas = {
+ meta: {
+ "uuid": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
+ "allOf": [ref(CHARACTERISTIC_TYPE)],
+ "properties": {
+ "first": ref(prop_cat),
+ "list": {"type": "array", "items": ref(items_cat)},
+ # the second reference to a bundled schema is not walked again
+ "again": ref(prop_cat),
+ },
+ },
+ CHARACTERISTIC_TYPE: {"uuid": "ffe74f29-1d35-4037-b318-c422591c5023"},
+ prop_cat: {"uuid": "11111111-2222-4333-8444-555555555555"},
+ items_cat: {"uuid": "66666666-7777-4888-9999-aaaaaaaaaaaa"},
+ }
+ requested = []
+
+ class Page:
+ def __init__(self, title):
+ self.title = title
+
+ def get_slot_content(self, slot):
+ return schemas[self.title]
+
+ def get_pages(titles):
+ requested.extend(titles)
+ return [Page(t) for t in titles]
+
+ titles = collect_category_template_titles(get_pages, [meta])
+
+ # allOf first, then properties in their order, then the uuid of the node itself
+ assert titles == [
+ "Category:Category",
+ CHARACTERISTIC_TYPE,
+ prop_cat,
+ items_cat,
+ meta,
+ ]
+ assert requested.count(prop_cat) == 1
From 799bc07952aac7e6ab579bc127c5cb3000710491 Mon Sep 17 00:00:00 2001
From: LukasGold <41064839+LukasGold@users.noreply.github.com>
Date: Thu, 5 Nov 2026 18:17:48 +0100
Subject: [PATCH 4/4] docs: state which schema references the template walk
follows (#220)
---
src/osw/core.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/osw/core.py b/src/osw/core.py
index dd16761..f476b1d 100644
--- a/src/osw/core.py
+++ b/src/osw/core.py
@@ -266,8 +266,9 @@ def collect_category_template_titles(
Category:Category and Category:Entity are not collected. Follows
osl.util.postProcessPage (PageBot.js) with editor.jsonschema.subschemas_uuids
(MwJson_schema.js, _preprocess) in mediawiki-extensions-OpenSemanticLab and
- mediawiki-extensions-MwJson. Only references to category pages are
- followed, a reference to another page, e.g. JsonSchema:Label, is not.
+ mediawiki-extensions-MwJson. A '/wiki/?action=raw' reference is
+ followed only for category pages, e.g. '/wiki/JsonSchema:Label?action=raw'
+ is not followed. A 'Special:SlotResolver' reference is followed for any page.
Parameters
----------