diff --git a/src/caosadvancedtools/table_json_conversion/fill_xlsx.py b/src/caosadvancedtools/table_json_conversion/fill_xlsx.py
new file mode 100644
index 0000000000000000000000000000000000000000..b9e1a93f3bf892b376cb07de4f1572dfa82e7120
--- /dev/null
+++ b/src/caosadvancedtools/table_json_conversion/fill_xlsx.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+# encoding: utf-8
+#
+# This file is a part of the LinkAhead Project.
+#
+# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com>
+# Copyright (C) 2024 Henrik tom Wörden <h.tomwoerden@indiscale.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+from openpyxl import load_workbook
+
+from .table_generator import ColumnType, RowType
+
+
+def _fill_leaves(json_doc: dict, workbook):
+    for key, value in json_doc:
+        if not isinstance(value, list):
+            value = [value]
+        for el in value:
+            if isinstance(el, dict):
+                _fill_leaves(el, workbook)
+            wb.cell(1,2, el)
+
+def _get_row_type_column(worksheet):
+    for col in worksheet.columns:
+        for cell in col:
+            if cell.value == RowType.COL_TYPE.name:
+                return cell.column
+    raise ValueError("The column which defines row types (COL_TYPE, PATH, ...) is missing")
+
+def _get_path_rows(worksheet):
+    rows = []
+    rt_col = _get_row_type_column(worksheet)
+    for cell in list(worksheet.columns)[rt_col-1]:
+        print(cell.value)
+        if cell.value == RowType.PATH.name:
+            rows.append(cell.row)
+    return rows
+
+
+
+def _generate_path_col_mapping(workbook):
+    rt_col = _get_row_type_column(workbook)
+
+    for col in workbook.columns:
+        pass
+
+
+def fill_template(template_path: str, json_path: str, result_path: str)-> None:
+    """
+    Fill the contents of the JSON document stored at ``json_path`` into the template stored at
+    ``template_path`` and store the result under ``result_path``.
+    """
+    template = load_workbook(template_path)
+    template.save(result_path)
diff --git a/src/caosadvancedtools/table_json_conversion/table_generator.py b/src/caosadvancedtools/table_json_conversion/table_generator.py
new file mode 100644
index 0000000000000000000000000000000000000000..f695aac36b34993cab56b2e5f59b2ef42a0fb47a
--- /dev/null
+++ b/src/caosadvancedtools/table_json_conversion/table_generator.py
@@ -0,0 +1,305 @@
+#!/usr/bin/env python3
+# encoding: utf-8
+#
+# This file is a part of the LinkAhead Project.
+#
+# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com>
+# Copyright (C) 2024 Henrik tom Wörden <h.tomwoerden@indiscale.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+"""
+This module allows to generate template tables from JSON schemas.
+"""
+
+import argparse
+import sys
+from abc import ABC, abstractmethod
+from argparse import RawTextHelpFormatter
+from enum import Enum
+from typing import Union
+
+from openpyxl import Workbook
+
+
+class ColumnType(Enum):
+    SCALAR = 1
+    LIST = 2
+    FOREIGN = 3
+
+
+class RowType(Enum):
+    COL_TYPE = 1
+    PATH = 2
+    IGNORE = 3
+
+
+class TableTemplateGenerator(ABC):
+    def __init__(self):
+        pass
+
+    @abstractmethod
+    def generate(self, schema: dict, foreign_keys: dict):
+        """ generates a sheet definition from a given JSON schema
+
+        Parameters:
+        -----------
+        schema: dict, given JSON schema
+        foreign_keys: dict, a configuration that defines which attributes shall be used to create
+                      additional columns when a list of references exists. Keys of the dict are
+                      elements of the path within the JSON where the key is needed. Suppose we want
+                      to distinguis Persons that are referenced by Trainings, foreign_keys must at
+                      least contain the following: {"Training": {"Person": [key1, key2]}}.
+                      Values wihtin the dicts can be a list representing the keys, a tuple where
+                      the first element is the list representing the keys and the second elment is
+                      a dict that allows to set further foreign keys at lower depth. The third
+                      possibility is a dict. In that case no foreign keys exist at that level (
+                      e.g. in the above example there is no foreign key for the path ["Training"].
+        """
+        pass
+
+    def _generate_sheets_from_schema(self, schema: dict, foreign_keys: dict = None
+                                     ) -> dict[str, dict[str, tuple[str, list]]]:
+        """ generates a sheet definition from a given JSON schema
+
+
+
+        Parameters
+        ----------
+        schema: dict
+            given JSON schema
+        foreign_keys: dict
+            a configuration that defines which attributes shall be used to create
+            additional columns when a list of references exists. See foreign_keys
+            argument of TableTemplateGenerator.generate.
+
+        Returns
+        -------
+            dict
+            The structure of the dict, lets call it ``sheets`` is as follows:
+            ``sheets[sheetname][colname]= (COL_TYPE, description [path])``
+            I.e. the top level dict contains sheet names as keys and dicts as values.
+            The inner dict has column names as keys and tuples as values. The tuples have the
+            column type as the first element, the description of the corresponding property as
+            second and the path as a list as last.
+
+        """
+        if not ("type" in schema or "anyOf" in schema):
+            raise ValueError("Inappropriate JSON schema: The following part should contain the "
+                             f"'type' key:\n{schema}\n")
+        if foreign_keys is None:
+            foreign_keys = {}
+        # here, we treat the top level
+        # sheets[sheetname][colname]= (COL_TYPE, [path])
+        sheets: dict[str, dict[str, tuple[str, list]]] = {}
+        if "properties" not in schema:
+            raise ValueError("Inappropriate JSON schema: The following part should contain "
+                             f"the 'properties' key:\n{schema}\n")
+        for RTname in schema["properties"].keys():
+            sheets[RTname] = self._treat_schema_element(schema["properties"][RTname],
+                                                        sheets, [RTname], foreign_keys)
+        return sheets
+
+    def _get_foreign_keys(self, foreign_keys: dict, path: list) -> list:
+        """ returns the foreign keys that are needed at the location to which path points """
+        path = list(path)
+        keys = foreign_keys
+        selected_keys = None
+        while path:
+            if keys is None:
+                raise ValueError(f"A foreign key definition is missing for path:"
+                                 f"\n{path}\n{foreign_keys}")
+            if path[0] not in keys:
+                raise ValueError(f"A foreign key definition is missing for path: \n{path}\n{keys}")
+            keys = keys[path[0]]
+            if isinstance(keys, tuple):
+                selected_keys, keys = keys
+            elif isinstance(keys, list):
+                selected_keys, keys = keys, None
+            else:
+                selected_keys, keys = None, keys
+            path = path[1:]
+        if selected_keys is None:
+            raise ValueError(f"A foreign key definition is missing for path:"
+                             f"\n{path}\n{foreign_keys}")
+        return selected_keys
+
+    def _treat_schema_element(self, schema: dict, sheets: dict = None, path: list = None,
+                              foreign_keys: dict = None
+                              ) -> dict[str, tuple[str, list]]:
+        """ recursively transforms elements from the schema into column definitions """
+        if not ("type" in schema or "enum" in schema or "oneOf" in schema or "anyOf" in schema):
+            raise ValueError("Inappropriate JSON schema: The following part should contain the "
+                             f"'type' key:\n{schema}\n")
+
+        ctype = ColumnType.SCALAR
+
+        # if it is an array, value defs are in 'items'
+        if 'type' in schema and schema['type'] == 'array':
+            if 'type' in schema['items'] and schema['items']['type'] == 'object':  # list of references; special treatment
+                # we add a new sheet
+                sheets[".".join(path)] = self._treat_schema_element(schema['items'], sheets, path, foreign_keys)
+                for c in self._get_foreign_keys(foreign_keys, path[:-1]):
+                    sheets[".".join(path)].update({".".join(path[:-1]+[c]): (
+                        ColumnType.FOREIGN, f"see sheet '{path[0]}'", path[:-1]+[c])})
+                # columns are added to the new sheet, thus we do not return columns
+                return {}
+            else:
+                # it is a list of primitive types -> semi colon separated list
+                schema = schema['items']
+                ctype = ColumnType.LIST
+
+        if 'oneOf' in schema:
+            for el in schema['oneOf']:
+                if 'type' in el:
+                    schema = el
+
+        if "properties" in schema:
+            # recurse for each property
+            cols = {}
+            for pname in schema["properties"].keys():
+                cols.update(self._treat_schema_element(
+                    schema["properties"][pname], sheets, path+[pname], foreign_keys))
+            return cols
+        else:
+            description = schema['description'] if 'description' in schema else None
+
+            # those are the leaves
+            if 'type' not in schema:
+                if 'enum' in schema:
+                    return {".".join(path[1:]): (ctype, description, path)}
+                if 'anyOf' in schema:
+                    for d in schema['anyOf']:
+                        # currently the only case where this occurs is date formats
+                        assert d['type'] == 'string'
+                        assert d['format'] == 'date' or d['format'] == 'date-time'
+                    return {".".join(path[1:]): (ctype, description, path)}
+            elif schema["type"] in ['string', 'number', 'integer', 'boolean']:
+                if 'format' in schema and schema['format'] == 'data-url':
+                    return {}  # file; ignore for now
+                return {".".join(path[1:]): (ctype, description, path)}
+            else:
+                raise ValueError("Inappropriate JSON schema: The following part should define an"
+                                 f" object with properties or a primitive type:\n{schema}\n")
+        raise RuntimeError("This should not be reached. Implementation error.")
+
+
+class XLSXTemplateGenerator(TableTemplateGenerator):
+    def __init__(self):
+        pass
+
+    def generate(self, schema: dict, foreign_keys: dict, filepath: str):
+        """ generates a sheet definition from a given JSON schema
+
+        Parameters:
+        -----------
+        schema: dict, given JSON schema
+        foreign_keys: dict, a configuration that defines which attributes shall be used to create
+                      additional columns when a list of references exists. See foreign_keys
+                      argument of TableTemplateGenerator.generate.
+        filepath: str, the XLSX file will be stored under this path.
+        """
+        sheets = self._generate_sheets_from_schema(schema, foreign_keys)
+        wb = self._create_workbook_from_sheets_def(sheets)
+        wb.save(filepath)
+
+    @staticmethod
+    def _get_max_path_length(sheetdef: dict) -> int:
+        """ returns the length of the longest path contained in the sheet definition
+
+        see TableTemplateGenerator._generate_sheets_from_schema for the structure of the sheets
+        definition dict
+        You need to pass the dict of a single sheet to this function.
+        """
+        return max([len(path) for _, _,  path in sheetdef.values()])
+
+    @staticmethod
+    def _get_ordered_cols(sheetdef: dict) -> list:
+        """
+        creates a list with tuples (colname, column type, path) where the foreign keys are first
+        """
+        ordered_cols = []
+        # first foreign cols
+        for colname, (ct, desc, path) in sheetdef.items():
+            if ct == ColumnType.FOREIGN:
+                ordered_cols.append((colname, ct, desc, path))
+        # now the other
+        for colname, (ct, desc, path) in sheetdef.items():
+            if ct != ColumnType.FOREIGN:
+                ordered_cols.append((colname, ct, desc, path))
+
+        return ordered_cols
+
+    def _create_workbook_from_sheets_def(self, sheets: dict[str, dict[str, tuple[str, list]]]):
+        wb = Workbook()
+        assert wb.sheetnames == ["Sheet"]
+        for sn, sheetdef in sheets.items():
+            ws = wb.create_sheet(sn)
+            # first row will by the COL_TYPE row
+            # first column will be the indicator row with values COL_TYPE, PATH, IGNORE
+            # the COL_TYPE row will be followed by as many PATH rows as needed
+
+            max_path_length = self._get_max_path_length(sheetdef)
+            header_index = 2+max_path_length
+            description_index = 3+max_path_length
+
+            # create first column
+            ws.cell(1, 1, RowType.COL_TYPE.name)
+            for index in range(max_path_length):
+                ws.cell(2+index, 1, RowType.PATH.name)
+            ws.cell(header_index, 1, RowType.IGNORE.name)
+            ws.cell(description_index, 1, RowType.IGNORE.name)
+
+            ordered_cols = self._get_ordered_cols(sheetdef)
+
+            # create other columns
+            for index, (colname, ct, desc, path) in enumerate(ordered_cols):
+                ws.cell(1, 2+index, ct.name)
+                for pi, el in enumerate(path):
+                    ws.cell(2+pi, 2+index, el)
+                ws.cell(header_index, 2+index, colname)
+                if desc:
+                    ws.cell(description_index, 2+index, desc)
+
+            # hide special rows
+            for index, row in enumerate(ws.rows):
+                if not (row[0].value is None or row[0].value == RowType.IGNORE.name):
+                    ws.row_dimensions[index+1].hidden = True
+
+            # hide special column
+            ws.column_dimensions['A'].hidden = True
+
+        # remove initial sheet
+        del wb['Sheet']
+
+        # order sheets
+        for index, sn in enumerate(sorted(wb.sheetnames)):
+            wb.move_sheet(sn, index-wb.index(wb[sn]))
+
+        return wb
+
+
+def parse_args():
+    parser = argparse.ArgumentParser(description=__doc__,
+                                     formatter_class=RawTextHelpFormatter)
+    parser.add_argument("path",
+                        help="the subtree of files below the given path will "
+                        "be considered. Use '/' for everything.")
+
+    return parser.parse_args()
+
+
+if __name__ == "__main__":
+    args = parse_args()
+    sys.exit(main(args))
diff --git a/src/doc/table-json-conversion/specs.md b/src/doc/table-json-conversion/specs.md
new file mode 100644
index 0000000000000000000000000000000000000000..3768ba1e101578b2347c51570126b0c34cd739f9
--- /dev/null
+++ b/src/doc/table-json-conversion/specs.md
@@ -0,0 +1,171 @@
+Top level of json must be a dict. keys of the dict are RT names.
+
+
+Frage: is the first RT never an array?
+
+
+Do not use sheet name, but only content of hidden rows
+
+
+
+Das Datenmodell in LinkAhead legt fest, welche Arten von Records es in einer LinkAhead-Instanz 
+gibt und wie diese aussehen. Diese Datenmodell kann auch in einem JSON Schema repräsentiert werden, 
+dass die Struktur von JSON Dateien festlegt, die zu dem Datenmodell gehörige Records enthält.
+Zum Beispiel kann das folgende JSON den Record einer Person beschreiben:
+```JSON
+{
+    "Person": {
+        "family_name": "Steve",
+        "given_name": "Stevie"
+    }
+}
+```
+
+Das JSON Schema schreibt eine konkrete Struktur vor und kann zugehörige JSON Dateien können genutzt werden 
+um Daten zu bestimmten Record Strukturen zu repräsentieren. Beispielsweise könnte man ein JSON Schema erstellen,
+dass es erlaubt "Training" Records mit Informationen zu abgehaltenen Trainings speichern. Dies ist insbesondere wertvoll
+beim Datenim- und export. Man könnte basierend auf dem im Folgenden beschriebenen Webformulare basieren 
+oder es nutzen um in LinkAhead gespeicherte Objekte als JSON zu exportieren.
+
+Im Folgenden wird beschrieben, wie JSON Dateien mit solchen Records in XLSX Dateien umgewandelt
+werden, bzw. wie aus XLSX-Dateien JSON Dateien mit Records erstellt werden.
+
+Der Attributname (oben "Person") legt den RecordType fest und der Wert diese Attributs kann 
+entweder ein Objekt oder eine Liste sein. Ist es ein Objekt (wie im obigen Beispiel), so wird ein
+einzelner Record repräsentiert. Bei einer Liste mehrere Records, die den gleichen RecordType als
+Parent haben.
+Die Properties des Records (oben "family_name" und "given_name") werden zu Spalten im XLSX.
+Die Properties haben wiederum einen Attributnamen und einen Wert. Der Wert kann 
+a. primitiv (Text, Zahl, Boolean, ...) 
+b. ein Record 
+c. eine Liste von primitiven Typen 
+d. eine Liste von Records
+sein.
+
+In den Fällen a. und c. wird in XLSX eine Zelle in der zur Property gehörigen Spalte erstellt. Im Fall b.
+wird prinzipiell für die Properties des Records Spalten erstellt. Tatsächlich wird der referenzierte Record genauso behandelt wie 
+der ursprüngliche. D.h. die Fälle a.-d. werden wieder für die einzelnen Properties betrachtet.
+Für den Fall d. ist die zweidimensionale Struktur eines XLSX Blatts nicht ausreichend. Daher werden für solche Fälle neue XLSX Blätter erstellt.
+In diesen werden die referenzierten Records behandelt wie oben beschrieben. Es gibt jedoch zusätzliche Spalten die es erlauben zu erkennen von 
+welchem Record die Records referenziert werden.
+
+Wir betrachten diese vier Fälle nun im Detail:
+a. Properties mit primitiven Datentypen 
+
+```JSON
+{
+    "Training": {
+        "date": "2023-01-01",
+        "url": "www.indiscale.com",
+        "duration": 1.0,
+        "participants": 1,
+        "remote": false
+    }
+}
+```
+Dies würde in einem XLSX Blatt mit dem folgenden Inhalt abgebildet:
+
+| date | url | duration | participants | remote |
+|------|-----|-------------------|-----|---|----|
+| 2023-01-01 | www.indiscale.com | 1.0 | 1 | false |
+
+b. Property, die einen Record referenziert
+
+
+```JSON
+{
+    "Training": {
+        "date": "2023-01-01",
+        "supervisor": {
+            "family_name": "Steve",
+            "given_name": "Stevie",
+        }
+    }
+}
+```
+
+Dies würde in einem XLSX Blatt mit dem folgenden Inhalt abgebildet:
+
+| date | supervisor.family_name | supervisor.given_name | 
+|------|-----|-------------------|
+| 2023-01-01 | Steve | Stevie | 
+
+Beachten Sie, dass die Spaltennamen umbenannt werden dürfen. Die Zuordnung der Spalte zu Properties von 
+Records wird über den Inhalt von versteckten Zeilen gewährleistet.
+
+c. Properties, die Listen mit Werten von primitiven Datentypen enthalten
+
+
+```JSON
+{
+    "Training": {
+        "url": "www.indiscale.com",
+        "subjects": ["Math", "Physics"],
+    }
+}
+```
+
+Dies würde in einem XLSX Blatt mit dem folgenden Inhalt abgebildet:
+
+| url | subjects |
+|------|-----|-------------------|-----|---|----|
+|  www.indiscale.com | Math;Physics |
+
+
+Die Listenelemente werden separiert von ``;`` in die Zelle geschrieben.
+Wenn die Elemente den Separator ``;`` enthalten, dann wird dieser mit einem ``\`` escaped.
+
+
+d. Properities, die Listen mit Referenzen enthalten
+
+
+```JSON
+{
+    "Training": {
+        "date": "2023-01-01",
+        "coach": [
+            {
+              "family_name": "Sky",
+              "given_name": "Max",
+              },{
+              "family_name": "Sky",
+              "given_name": "Min",
+              }],
+    }
+}
+```
+
+Da die beiden Coaches nicht vernünftig in einer Zelle dargestellt werden können, bedarf es nun eines 
+weiteren Tabellenblatts, das die Eigenschaften der Coaches enthält.
+
+Das Blatt zu den Trainings enthält in diesem Beispiel nur die "date" Spalte:
+
+| date |
+|------|
+| 2023-01-01 |
+
+Zusätzlich gibt es ein weiteres Blatt in dem die Coaches gespeichert werden.
+Hier ist nun entscheidend, dass definiert wird, wie von potentiell mehreren "Trainings" das richtige Element gewählt wird.
+In diesem Fall bedeutet dies, dass das "date" eindeutig sein muss
+TODO: In welchem Scope gilt diese Eindeutigkeit? Können wir dies checken?
+Das zweite Blatt sieht dann wie folgt aus
+
+| date | coach.family_name | coach.given_name |
+|------|-|-|
+| 2023-01-01 | Sky | Max|
+| 2023-01-01 | Sky | Min|
+
+
+
+# Hidden automation logic
+
+The first column in each sheet will be hidden and it will contain an entry in each row that needs special
+treatment. The following values are used:
+``COL_TYPE``: typically the first row. It indicates the row that defines the type of columns (``FOREIGN`` or ``VALUE``).
+``PATH``: indicates that the row is used to define the path within the JSON
+``IGNROE``: row is ignored; It can be used for explanatory texts or layout
+
+If we want to put the value of a given cell into the JSON, we traverse all path elements given in rows with the ``PATH`` value from row with lowest index to the one with the highest index. The final element of the path is the name of the Property of which the value
+needs to be set. The elements of the path are sufficient to identify the object within the JSON if the value of the corresponding key is 
+an object. If the value is an array, the appropriate object within the array needs to be selected.
+For this selection additional ``FOREIGN`` columns are used. The path given in those columns is the path to the level where the object needs to be chosen plus the name of the attribute that is used to select the correct object. 
diff --git a/unittests/table_json_conversion/.specs.md.html b/unittests/table_json_conversion/.specs.md.html
new file mode 100644
index 0000000000000000000000000000000000000000..2181eaf710e4d51f1df5b0c5c388d06873e97408
--- /dev/null
+++ b/unittests/table_json_conversion/.specs.md.html
@@ -0,0 +1,310 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" lang="" xml:lang="">
+<head>
+  <meta charset="utf-8" />
+  <meta name="generator" content="pandoc" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
+  <title>specs.md</title>
+  <style>
+    code{white-space: pre-wrap;}
+    span.smallcaps{font-variant: small-caps;}
+    div.columns{display: flex; gap: min(4vw, 1.5em);}
+    div.column{flex: auto; overflow-x: auto;}
+    div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
+    /* The extra [class] is a hack that increases specificity enough to
+       override a similar rule in reveal.js */
+    ul.task-list[class]{list-style: none;}
+    ul.task-list li input[type="checkbox"] {
+      font-size: inherit;
+      width: 0.8em;
+      margin: 0 0.8em 0.2em -1.6em;
+      vertical-align: middle;
+    }
+    .display.math{display: block; text-align: center; margin: 0.5rem auto;}
+    /* CSS for syntax highlighting */
+    pre > code.sourceCode { white-space: pre; position: relative; }
+    pre > code.sourceCode > span { line-height: 1.25; }
+    pre > code.sourceCode > span:empty { height: 1.2em; }
+    .sourceCode { overflow: visible; }
+    code.sourceCode > span { color: inherit; text-decoration: inherit; }
+    div.sourceCode { margin: 1em 0; }
+    pre.sourceCode { margin: 0; }
+    @media screen {
+    div.sourceCode { overflow: auto; }
+    }
+    @media print {
+    pre > code.sourceCode { white-space: pre-wrap; }
+    pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
+    }
+    pre.numberSource code
+      { counter-reset: source-line 0; }
+    pre.numberSource code > span
+      { position: relative; left: -4em; counter-increment: source-line; }
+    pre.numberSource code > span > a:first-child::before
+      { content: counter(source-line);
+        position: relative; left: -1em; text-align: right; vertical-align: baseline;
+        border: none; display: inline-block;
+        -webkit-touch-callout: none; -webkit-user-select: none;
+        -khtml-user-select: none; -moz-user-select: none;
+        -ms-user-select: none; user-select: none;
+        padding: 0 4px; width: 4em;
+        color: #aaaaaa;
+      }
+    pre.numberSource { margin-left: 3em; border-left: 1px solid #aaaaaa;  padding-left: 4px; }
+    div.sourceCode
+      {   }
+    @media screen {
+    pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; }
+    }
+    code span.al { color: #ff0000; font-weight: bold; } /* Alert */
+    code span.an { color: #60a0b0; font-weight: bold; font-style: italic; } /* Annotation */
+    code span.at { color: #7d9029; } /* Attribute */
+    code span.bn { color: #40a070; } /* BaseN */
+    code span.bu { color: #008000; } /* BuiltIn */
+    code span.cf { color: #007020; font-weight: bold; } /* ControlFlow */
+    code span.ch { color: #4070a0; } /* Char */
+    code span.cn { color: #880000; } /* Constant */
+    code span.co { color: #60a0b0; font-style: italic; } /* Comment */
+    code span.cv { color: #60a0b0; font-weight: bold; font-style: italic; } /* CommentVar */
+    code span.do { color: #ba2121; font-style: italic; } /* Documentation */
+    code span.dt { color: #902000; } /* DataType */
+    code span.dv { color: #40a070; } /* DecVal */
+    code span.er { color: #ff0000; font-weight: bold; } /* Error */
+    code span.ex { } /* Extension */
+    code span.fl { color: #40a070; } /* Float */
+    code span.fu { color: #06287e; } /* Function */
+    code span.im { color: #008000; font-weight: bold; } /* Import */
+    code span.in { color: #60a0b0; font-weight: bold; font-style: italic; } /* Information */
+    code span.kw { color: #007020; font-weight: bold; } /* Keyword */
+    code span.op { color: #666666; } /* Operator */
+    code span.ot { color: #007020; } /* Other */
+    code span.pp { color: #bc7a00; } /* Preprocessor */
+    code span.sc { color: #4070a0; } /* SpecialChar */
+    code span.ss { color: #bb6688; } /* SpecialString */
+    code span.st { color: #4070a0; } /* String */
+    code span.va { color: #19177c; } /* Variable */
+    code span.vs { color: #4070a0; } /* VerbatimString */
+    code span.wa { color: #60a0b0; font-weight: bold; font-style: italic; } /* Warning */
+  </style>
+  <link rel="stylesheet" href="/home/henrik/Documents/programmierung/markdown-viewer/markdown-styles/markdown5.css" />
+  <!--[if lt IE 9]>
+    <script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv-printshiv.min.js"></script>
+  <![endif]-->
+</head>
+<body>
+<p>Top level of json must be a dict. keys of the dict are RT names.</p>
+<p>Frage: is the first RT never an array?</p>
+<p>Do not use sheet name, but only content of hidden rows</p>
+<p>Das Datenmodell in LinkAhead legt fest, welche Arten von Records es
+in einer LinkAhead-Instanz gibt und wie diese aussehen. Diese
+Datenmodell kann auch in einem JSON Schema repräsentiert werden, dass
+die Struktur von JSON Dateien festlegt, die zu dem Datenmodell gehörige
+Records enthält. Zum Beispiel kann das folgende JSON den Record einer
+Person beschreiben:</p>
+<div class="sourceCode" id="cb1"><pre
+class="sourceCode json"><code class="sourceCode json"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
+<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a>    <span class="dt">&quot;Person&quot;</span><span class="fu">:</span> <span class="fu">{</span></span>
+<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;family_name&quot;</span><span class="fu">:</span> <span class="st">&quot;Steve&quot;</span><span class="fu">,</span></span>
+<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;given_name&quot;</span><span class="fu">:</span> <span class="st">&quot;Stevie&quot;</span></span>
+<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a>    <span class="fu">}</span></span>
+<span id="cb1-6"><a href="#cb1-6" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></code></pre></div>
+<p>Das JSON Schema schreibt eine konkrete Struktur vor und kann
+zugehörige JSON Dateien können genutzt werden um Daten zu bestimmten
+Record Strukturen zu repräsentieren. Beispielsweise könnte man ein JSON
+Schema erstellen, dass es erlaubt “Training” Records mit Informationen
+zu abgehaltenen Trainings speichern. Dies ist insbesondere wertvoll beim
+Datenim- und export. Man könnte basierend auf dem im Folgenden
+beschriebenen Webformulare basieren oder es nutzen um in LinkAhead
+gespeicherte Objekte als JSON zu exportieren.</p>
+<p>Im Folgenden wird beschrieben, wie JSON Dateien mit solchen Records
+in XLSX Dateien umgewandelt werden, bzw. wie aus XLSX-Dateien JSON
+Dateien mit Records erstellt werden.</p>
+<p>Der Attributname (oben “Person”) legt den RecordType fest und der
+Wert diese Attributs kann entweder ein Objekt oder eine Liste sein. Ist
+es ein Objekt (wie im obigen Beispiel), so wird ein einzelner Record
+repräsentiert. Bei einer Liste mehrere Records, die den gleichen
+RecordType als Parent haben. Die Properties des Records (oben
+“family_name” und “given_name”) werden zu Spalten im XLSX. Die
+Properties haben wiederum einen Attributnamen und einen Wert. Der Wert
+kann a. primitiv (Text, Zahl, Boolean, …) b. ein Record c. eine Liste
+von primitiven Typen d. eine Liste von Records sein.</p>
+<p>In den Fällen a. und c. wird in XLSX eine Zelle in der zur Property
+gehörigen Spalte erstellt. Im Fall b. wird prinzipiell für die
+Properties des Records Spalten erstellt. Tatsächlich wird der
+referenzierte Record genauso behandelt wie der ursprüngliche. D.h. die
+Fälle a.-d. werden wieder für die einzelnen Properties betrachtet. Für
+den Fall d. ist die zweidimensionale Struktur eines XLSX Blatts nicht
+ausreichend. Daher werden für solche Fälle neue XLSX Blätter erstellt.
+In diesen werden die referenzierten Records behandelt wie oben
+beschrieben. Es gibt jedoch zusätzliche Spalten die es erlauben zu
+erkennen von welchem Record die Records referenziert werden.</p>
+<p>Wir betrachten diese vier Fälle nun im Detail: a. Properties mit
+primitiven Datentypen</p>
+<div class="sourceCode" id="cb2"><pre
+class="sourceCode json"><code class="sourceCode json"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
+<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a>    <span class="dt">&quot;Training&quot;</span><span class="fu">:</span> <span class="fu">{</span></span>
+<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;date&quot;</span><span class="fu">:</span> <span class="st">&quot;2023-01-01&quot;</span><span class="fu">,</span></span>
+<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;url&quot;</span><span class="fu">:</span> <span class="st">&quot;www.indiscale.com&quot;</span><span class="fu">,</span></span>
+<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;duration&quot;</span><span class="fu">:</span> <span class="fl">1.0</span><span class="fu">,</span></span>
+<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;participants&quot;</span><span class="fu">:</span> <span class="dv">1</span><span class="fu">,</span></span>
+<span id="cb2-7"><a href="#cb2-7" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;remote&quot;</span><span class="fu">:</span> <span class="kw">false</span></span>
+<span id="cb2-8"><a href="#cb2-8" aria-hidden="true" tabindex="-1"></a>    <span class="fu">}</span></span>
+<span id="cb2-9"><a href="#cb2-9" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></code></pre></div>
+<p>Dies würde in einem XLSX Blatt mit dem folgenden Inhalt
+abgebildet:</p>
+<table>
+<thead>
+<tr class="header">
+<th>date</th>
+<th>url</th>
+<th>duration</th>
+<th>participants</th>
+<th>remote</th>
+<th></th>
+</tr>
+</thead>
+<tbody>
+<tr class="odd">
+<td>2023-01-01</td>
+<td>www.indiscale.com</td>
+<td>1.0</td>
+<td>1</td>
+<td>false</td>
+<td></td>
+</tr>
+</tbody>
+</table>
+<ol start="2" type="a">
+<li>Property, die einen Record referenziert</li>
+</ol>
+<div class="sourceCode" id="cb3"><pre
+class="sourceCode json"><code class="sourceCode json"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
+<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a>    <span class="dt">&quot;Training&quot;</span><span class="fu">:</span> <span class="fu">{</span></span>
+<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;date&quot;</span><span class="fu">:</span> <span class="st">&quot;2023-01-01&quot;</span><span class="fu">,</span></span>
+<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;supervisor&quot;</span><span class="fu">:</span> <span class="fu">{</span></span>
+<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a>            <span class="dt">&quot;family_name&quot;</span><span class="fu">:</span> <span class="st">&quot;Steve&quot;</span><span class="fu">,</span></span>
+<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a>            <span class="dt">&quot;given_name&quot;</span><span class="fu">:</span> <span class="st">&quot;Stevie&quot;</span><span class="fu">,</span></span>
+<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a>        <span class="fu">}</span></span>
+<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a>    <span class="fu">}</span></span>
+<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></code></pre></div>
+<p>Dies würde in einem XLSX Blatt mit dem folgenden Inhalt
+abgebildet:</p>
+<table>
+<thead>
+<tr class="header">
+<th>date</th>
+<th>supervisor.family_name</th>
+<th>supervisor.given_name</th>
+</tr>
+</thead>
+<tbody>
+<tr class="odd">
+<td>2023-01-01</td>
+<td>Steve</td>
+<td>Stevie</td>
+</tr>
+</tbody>
+</table>
+<p>Beachten Sie, dass die Spaltennamen umbenannt werden dürfen. Die
+Zuordnung der Spalte zu Properties von Records wird über den Inhalt von
+versteckten Zeilen gewährleistet.</p>
+<ol start="3" type="a">
+<li>Properties, die Listen mit Werten von primitiven Datentypen
+enthalten</li>
+</ol>
+<div class="sourceCode" id="cb4"><pre
+class="sourceCode json"><code class="sourceCode json"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
+<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a>    <span class="dt">&quot;Training&quot;</span><span class="fu">:</span> <span class="fu">{</span></span>
+<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;url&quot;</span><span class="fu">:</span> <span class="st">&quot;www.indiscale.com&quot;</span><span class="fu">,</span></span>
+<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;subjects&quot;</span><span class="fu">:</span> <span class="ot">[</span><span class="st">&quot;Math&quot;</span><span class="ot">,</span> <span class="st">&quot;Physics&quot;</span><span class="ot">]</span><span class="fu">,</span></span>
+<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a>    <span class="fu">}</span></span>
+<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></code></pre></div>
+<p>Dies würde in einem XLSX Blatt mit dem folgenden Inhalt
+abgebildet:</p>
+<table>
+<thead>
+<tr class="header">
+<th>url</th>
+<th>subjects</th>
+<th></th>
+<th></th>
+<th></th>
+<th></th>
+</tr>
+</thead>
+<tbody>
+<tr class="odd">
+<td>www.indiscale.com</td>
+<td>Math;Physics</td>
+<td></td>
+<td></td>
+<td></td>
+<td></td>
+</tr>
+</tbody>
+</table>
+<p>Die Listenelemente werden separiert von <code>;</code> in die Zelle
+geschrieben.</p>
+<ol start="4" type="a">
+<li>Properities, die Listen mit Referenzen enthalten</li>
+</ol>
+<div class="sourceCode" id="cb5"><pre
+class="sourceCode json"><code class="sourceCode json"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
+<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a>    <span class="dt">&quot;Training&quot;</span><span class="fu">:</span> <span class="fu">{</span></span>
+<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;date&quot;</span><span class="fu">:</span> <span class="st">&quot;2023-01-01&quot;</span><span class="fu">,</span></span>
+<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a>        <span class="dt">&quot;coach&quot;</span><span class="fu">:</span> <span class="ot">[</span></span>
+<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a>            <span class="fu">{</span></span>
+<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a>              <span class="dt">&quot;family_name&quot;</span><span class="fu">:</span> <span class="st">&quot;Sky&quot;</span><span class="fu">,</span></span>
+<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a>              <span class="dt">&quot;given_name&quot;</span><span class="fu">:</span> <span class="st">&quot;Max&quot;</span><span class="fu">,</span></span>
+<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a>              <span class="fu">}</span><span class="ot">,</span><span class="fu">{</span></span>
+<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a>              <span class="dt">&quot;family_name&quot;</span><span class="fu">:</span> <span class="st">&quot;Sky&quot;</span><span class="fu">,</span></span>
+<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a>              <span class="dt">&quot;given_name&quot;</span><span class="fu">:</span> <span class="st">&quot;Min&quot;</span><span class="fu">,</span></span>
+<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a>              <span class="fu">}</span><span class="ot">]</span><span class="fu">,</span></span>
+<span id="cb5-12"><a href="#cb5-12" aria-hidden="true" tabindex="-1"></a>    <span class="fu">}</span></span>
+<span id="cb5-13"><a href="#cb5-13" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></code></pre></div>
+<p>Da die beiden Coaches nicht vernünftig in einer Zelle dargestellt
+werden können, bedarf es nun eines weiteren Tabellenblatts, das die
+Eigenschaften der Coaches enthält.</p>
+<p>Das Blatt zu den Trainings enthält in diesem Beispiel nur die “date”
+Spalte:</p>
+<table>
+<thead>
+<tr class="header">
+<th>date</th>
+</tr>
+</thead>
+<tbody>
+<tr class="odd">
+<td>2023-01-01</td>
+</tr>
+</tbody>
+</table>
+<p>Zusätzlich gibt es ein weiteres Blatt in dem die Coaches gespeichert
+werden. Hier ist nun entscheidend, dass definiert wird, wie von
+potentiell mehreren “Trainings” das richtige Element gewählt wird. In
+diesem Fall bedeutet dies, dass das “date” eindeutig sein muss TODO: In
+welchem Scope gilt diese Eindeutigkeit? Können wir dies checken? Das
+zweite Blatt sieht dann wie folgt aus</p>
+<table>
+<thead>
+<tr class="header">
+<th>date</th>
+<th>coach.family_name</th>
+<th>coach.given_name</th>
+</tr>
+</thead>
+<tbody>
+<tr class="odd">
+<td>2023-01-01</td>
+<td>Sky</td>
+<td>Max</td>
+</tr>
+<tr class="even">
+<td>2023-01-01</td>
+<td>Sky</td>
+<td>Min</td>
+</tr>
+</tbody>
+</table>
+</body>
+</html>
diff --git a/unittests/table_json_conversion/create_jsonschema.py b/unittests/table_json_conversion/create_jsonschema.py
new file mode 100755
index 0000000000000000000000000000000000000000..8e1227183c931a12cec9d314b0a523fbfc51cc45
--- /dev/null
+++ b/unittests/table_json_conversion/create_jsonschema.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2023 IndiScale GmbH <www.indiscale.com>
+# Copyright (C) 2023 Daniel Hornung <d.hornung@indiscale.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+"""Create JSON-Schema according to configuration
+"""
+
+import argparse
+import json
+import os
+import sys
+
+import caosadvancedtools.json_schema_exporter as jsex
+import caosadvancedtools.models.parser as parser
+import tomli
+
+# TODO why do I need a running LA instance?
+
+def prepare_datamodel():
+    model = parser.parse_model_from_yaml("./model.yml")
+
+    exporter = jsex.JsonSchemaExporter(additional_properties=False,
+                                       #additional_options_for_text_props=additional_text_options,
+                                       #name_and_description_in_properties=True,
+                                       name_property_for_new_records=True,
+                                       #do_not_create=do_not_create,
+                                       #do_not_retrieve=do_not_retrieve,
+                                       )
+    schema_top = exporter.recordtype_to_json_schema(model.get_deep("Training"))
+    schema_pers = exporter.recordtype_to_json_schema(model.get_deep("Person"))
+    merged_schema = jsex.merge_schemas([schema_top, schema_pers])
+
+    with open("model_schema.json", mode="w", encoding="utf8") as json_file:
+        json.dump(merged_schema, json_file, ensure_ascii=False, indent=2)
+
+
+def _parse_arguments():
+    """Parse the arguments."""
+    parser = argparse.ArgumentParser(description='')
+
+    return parser.parse_args()
+
+
+def main():
+    """The main function of this script."""
+    args = _parse_arguments()
+    prepare_datamodel()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/unittests/table_json_conversion/example.json b/unittests/table_json_conversion/example.json
new file mode 100644
index 0000000000000000000000000000000000000000..542162a3b99f4904bef0afc352d31e3937cf244d
--- /dev/null
+++ b/unittests/table_json_conversion/example.json
@@ -0,0 +1,42 @@
+{
+    "Training": {
+        "date": "2023-01-01",
+        "url": "www.indiscale.com",
+        "coach": [
+            {
+              "family_name": "Sky",
+              "given_name": "Max",
+              "Organisation": {
+                  "name": "ECB",
+                  "Country": "Germany"
+                }
+              },{
+              "family_name": "Sky",
+              "given_name": "Min",
+              "Organisation": {
+                  "name": "ECB",
+                  "Country": "Germany"
+                }
+              }],
+        "supervisor": {
+            "family_name": "Steve",
+            "given_name": "Stevie",
+            "Organisation": {
+                "name": "IMF",
+                "Country": "Belgium"
+              }
+        },
+        "duration": 1.0,
+        "participants": 1,
+        "subjects": ["Math", "Physics"],
+        "remote": false
+    },
+    "Person": {
+            "family_name": "Steve",
+            "given_name": "Stevie",
+            "Organisation": {
+                "name": "IMF",
+                "Country": "Belgium"
+              }
+        }
+}
diff --git a/unittests/table_json_conversion/example.xlsx b/unittests/table_json_conversion/example.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..23530ad18c2a1442160d0c1a478a25dc887ac900
Binary files /dev/null and b/unittests/table_json_conversion/example.xlsx differ
diff --git a/unittests/table_json_conversion/example_template.xlsx b/unittests/table_json_conversion/example_template.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..e6b24f25ad52b40991708c4fc710d25eaabd741e
Binary files /dev/null and b/unittests/table_json_conversion/example_template.xlsx differ
diff --git a/unittests/table_json_conversion/how_to_schema b/unittests/table_json_conversion/how_to_schema
new file mode 100644
index 0000000000000000000000000000000000000000..a7b4e3ca35a1fc9e67ebbb29f316825e89596f4a
--- /dev/null
+++ b/unittests/table_json_conversion/how_to_schema
@@ -0,0 +1,19 @@
+Insert the data model into a LinkAhead server.
+
+Run the following code:
+```
+    model = parser.parse_model_from_yaml("./model.yml")
+
+    exporter = jsex.JsonSchemaExporter(additional_properties=False,
+                                       #additional_options_for_text_props=additional_text_options,
+                                       #name_and_description_in_properties=True,
+                                       #do_not_create=do_not_create,
+                                       #do_not_retrieve=do_not_retrieve,
+                                       )
+    schema_top = exporter.recordtype_to_json_schema(model.get_deep("Training"))
+    schema_pers = exporter.recordtype_to_json_schema(model.get_deep("Person"))
+    merged_schema = jsex.merge_schemas([schema_top, schema_pers])
+
+    with open("model_schema.json", mode="w", encoding="utf8") as json_file:
+        json.dump(merged_schema, json_file, ensure_ascii=False, indent=2)
+```
diff --git a/unittests/table_json_conversion/model.yml b/unittests/table_json_conversion/model.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9c6f5e6781077119f5b1538abe4ae93b0471bc78
--- /dev/null
+++ b/unittests/table_json_conversion/model.yml
@@ -0,0 +1,34 @@
+Person:
+  recommended_properties:
+    family_name:
+      datatype: TEXT
+    given_name:
+      datatype: TEXT
+    Organisation:
+Training:
+  recommended_properties:
+    date:
+      datatype: DATETIME
+      description: 'date'
+    url:
+      datatype: TEXT
+      description: 'url'
+    subjects:
+      datatype: LIST<TEXT>
+    coach:
+      datatype: LIST<Person>
+    supervisor:
+      datatype: Person
+    duration:
+      datatype: DOUBLE
+    participants:
+      datatype: INTEGER
+    remote:
+      datatype: BOOLEAN
+ProgrammingCourse:
+  inherit_from_suggested:
+    - Training
+Organisation:
+  recommended_properties:
+    Country:
+      datatype: TEXT
diff --git a/unittests/table_json_conversion/model_schema.json b/unittests/table_json_conversion/model_schema.json
new file mode 100644
index 0000000000000000000000000000000000000000..4be94811e124226d88cefda9fb19428364f8506d
--- /dev/null
+++ b/unittests/table_json_conversion/model_schema.json
@@ -0,0 +1,159 @@
+{
+  "type": "object",
+  "properties": {
+    "Training": {
+      "type": "object",
+      "required": [],
+      "additionalProperties": false,
+      "title": "Training",
+      "properties": {
+        "name": {
+          "type": "string",
+          "description": "The name of the Record to be created"
+        },
+        "date": {
+          "description": "date",
+          "anyOf": [
+            {
+              "type": "string",
+              "format": "date"
+            },
+            {
+              "type": "string",
+              "format": "date-time"
+            }
+          ]
+        },
+        "url": {
+          "type": "string",
+          "description": "url"
+        },
+        "subjects": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "coach": {
+          "type": "array",
+          "items": {
+            "type": "object",
+            "required": [],
+            "additionalProperties": false,
+            "title": "coach",
+            "properties": {
+              "name": {
+                "type": "string",
+                "description": "The name of the Record to be created"
+              },
+              "family_name": {
+                "type": "string"
+              },
+              "given_name": {
+                "type": "string"
+              },
+              "Organisation": {
+                "type": "object",
+                "required": [],
+                "additionalProperties": false,
+                "title": "Organisation",
+                "properties": {
+                  "name": {
+                    "type": "string",
+                    "description": "The name of the Record to be created"
+                  },
+                  "Country": {
+                    "type": "string"
+                  }
+                }
+              }
+            }
+          }
+        },
+        "supervisor": {
+          "type": "object",
+          "required": [],
+          "additionalProperties": false,
+          "title": "supervisor",
+          "properties": {
+            "name": {
+              "type": "string",
+              "description": "The name of the Record to be created"
+            },
+            "family_name": {
+              "type": "string"
+            },
+            "given_name": {
+              "type": "string"
+            },
+            "Organisation": {
+              "type": "object",
+              "required": [],
+              "additionalProperties": false,
+              "title": "Organisation",
+              "properties": {
+                "name": {
+                  "type": "string",
+                  "description": "The name of the Record to be created"
+                },
+                "Country": {
+                  "type": "string"
+                }
+              }
+            }
+          }
+        },
+        "duration": {
+          "type": "number"
+        },
+        "participants": {
+          "type": "integer"
+        },
+        "remote": {
+          "type": "boolean"
+        }
+      },
+      "$schema": "https://json-schema.org/draft/2020-12/schema"
+    },
+    "Person": {
+      "type": "object",
+      "required": [],
+      "additionalProperties": false,
+      "title": "Person",
+      "properties": {
+        "name": {
+          "type": "string",
+          "description": "The name of the Record to be created"
+        },
+        "family_name": {
+          "type": "string"
+        },
+        "given_name": {
+          "type": "string"
+        },
+        "Organisation": {
+          "type": "object",
+          "required": [],
+          "additionalProperties": false,
+          "title": "Organisation",
+          "properties": {
+            "name": {
+              "type": "string",
+              "description": "The name of the Record to be created"
+            },
+            "Country": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "$schema": "https://json-schema.org/draft/2020-12/schema"
+    }
+  },
+  "required": [
+    "Training",
+    "Person"
+  ],
+  "additionalProperties": false,
+  "$schema": "https://json-schema.org/draft/2020-12/schema"
+}
\ No newline at end of file
diff --git a/unittests/table_json_conversion/test_fill_xlsx.py b/unittests/table_json_conversion/test_fill_xlsx.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f9936c101472537ab4337bc842177d1dcc294b9
--- /dev/null
+++ b/unittests/table_json_conversion/test_fill_xlsx.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+# encoding: utf-8
+#
+# This file is a part of the LinkAhead Project.
+#
+# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com>
+# Copyright (C) 2024 Henrik tom Wörden <h.tomwoerden@indiscale.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import os
+import tempfile
+
+from caosadvancedtools.table_json_conversion.fill_xlsx import (
+    _get_path_rows, _get_row_type_column, fill_template)
+from openpyxl import load_workbook
+
+
+def rfp(*pathcomponents):
+    """
+    Return full path.
+    Shorthand convenience function.
+    """
+    return os.path.join(os.path.dirname(__file__), *pathcomponents)
+
+
+def test_detect():
+    example = load_workbook(rfp("example_template.xlsx"))
+    assert 1 == _get_row_type_column(example['Person'])
+    assert [2,3,4] == _get_path_rows(example['Person'])
+
+def test_fill_xlsx():
+    path = os.path.join(tempfile.mkdtemp(), 'test.xlsx')
+    assert not os.path.exists(path)
+    fill_template(rfp('example_template.xlsx'), rfp('example.json'), path )
+    assert os.path.exists(path)
+    generated = load_workbook(path)  # workbook can be read
diff --git a/unittests/table_json_conversion/test_table_template_generator.py b/unittests/table_json_conversion/test_table_template_generator.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc67f435eea8d5a73bc34f7f918ee8ae5435a0ed
--- /dev/null
+++ b/unittests/table_json_conversion/test_table_template_generator.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+# encoding: utf-8
+#
+# This file is a part of the LinkAhead Project.
+#
+# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com>
+# Copyright (C) 2024 Henrik tom Wörden <h.tomwoerden@indiscale.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import json
+import os
+import tempfile
+
+import pytest
+from caosadvancedtools.table_json_conversion.table_generator import (
+    ColumnType, XLSXTemplateGenerator)
+from openpyxl import load_workbook
+
+
+def rfp(*pathcomponents):
+    """
+    Return full path.
+    Shorthand convenience function.
+    """
+    return os.path.join(os.path.dirname(__file__), *pathcomponents)
+
+
+def test_generate_sheets_from_schema():
+    # trivial case; we do not support this
+    schema = {}
+    generator = XLSXTemplateGenerator()
+    with pytest.raises(ValueError, match="Inappropriate JSON schema:.*"):
+        generator._generate_sheets_from_schema(schema)
+
+    # top level must be RT with Properties
+    schema = {
+        "type": "string"
+    }
+    with pytest.raises(ValueError, match="Inappropriate JSON schema:.*"):
+        generator._generate_sheets_from_schema(schema)
+
+    # bad type
+    schema = {
+        "type": "object",
+        "properties": {
+            "Training": {
+                "type": "object",
+                "properties": {
+                    "name": {
+                        "type": "str",
+                        "description": "The name of the Record to be created"
+                    },
+                }
+            }
+        }
+    }
+    with pytest.raises(ValueError,
+                       match="Inappropriate JSON schema: The following part "
+                       "should define an object.*"):
+        generator._generate_sheets_from_schema(schema)
+
+    # bad schema
+    schema = {
+        "type": "object",
+        "properties": {
+            "Training": {
+                "type": "object"
+            }
+        }
+    }
+    with pytest.raises(ValueError,
+                       match="Inappropriate JSON schema: The following part "
+                       "should define an object.*"):
+        generator._generate_sheets_from_schema(schema)
+
+    # minimal case: one RT with one P
+    schema = {
+        "type": "object",
+        "properties": {
+            "Training": {
+                "type": "object",
+                "properties": {
+                    "name": {
+                        "type": "string",
+                        "description": "The name of the Record to be created"
+                    },
+                }
+            }
+        }
+    }
+    sdef = generator._generate_sheets_from_schema(schema)
+    assert "Training" in sdef
+    tdef = sdef['Training']
+    assert 'name' in tdef
+    assert tdef['name'] == (ColumnType.SCALAR, ["Training", 'name'])
+
+    # example case
+    with open(rfp("model_schema.json")) as sfi:
+        schema = json.load(sfi)
+    with pytest.raises(ValueError, match="A foreign key definition is missing.*"):
+        generator._generate_sheets_from_schema(schema)
+    sdef = generator._generate_sheets_from_schema(schema,
+                                                  foreign_keys={'Training': (['date', 'url'], {})})
+    assert "Training" in sdef
+    tdef = sdef['Training']
+    assert tdef['date'] == (ColumnType.SCALAR, ["Training", 'date'])
+    assert tdef['url'] == (ColumnType.SCALAR, ["Training", 'url'])
+    assert tdef['supervisor.family_name'] == (ColumnType.SCALAR, ["Training", 'supervisor',
+                                                                  'family_name'])
+    assert tdef['supervisor.given_name'] == (ColumnType.SCALAR, ["Training", 'supervisor',
+                                                                 'given_name'])
+    assert tdef['supervisor.Organisation.name'] == (ColumnType.SCALAR, ["Training", 'supervisor',
+                                                                        'Organisation', 'name'])
+    assert tdef['supervisor.Organisation.Country'] == (ColumnType.SCALAR, ["Training", 'supervisor',
+                                                                           'Organisation', 'Country'])
+    assert tdef['duration'] == (ColumnType.SCALAR, ["Training", 'duration'])
+    assert tdef['participants'] == (ColumnType.SCALAR, ["Training", 'participants'])
+    assert tdef['subjects'] == (ColumnType.LIST, ["Training", 'subjects'])
+    assert tdef['remote'] == (ColumnType.SCALAR, ["Training", 'remote'])
+    cdef = sdef['Training.coach']
+    assert cdef['coach.family_name'] == (ColumnType.SCALAR, ["Training", 'coach', 'family_name'])
+    assert cdef['coach.given_name'] == (ColumnType.SCALAR, ["Training", 'coach', 'given_name'])
+    assert cdef['coach.Organisation.name'] == (ColumnType.SCALAR, ["Training", 'coach',
+                                                                   'Organisation', 'name'])
+    assert cdef['coach.Organisation.Country'] == (ColumnType.SCALAR, ["Training", 'coach',
+                                                                      'Organisation', 'Country'])
+    assert cdef['date'] == (ColumnType.FOREIGN, ["Training", 'date'])
+    assert cdef['url'] == (ColumnType.FOREIGN, ["Training", 'url'])
+
+
+def test_get_foreign_keys():
+    generator = XLSXTemplateGenerator()
+    fkd = {"Training": ['a']}
+    assert ['a'] == generator._get_foreign_keys(fkd, ['Training'])
+
+    fkd = {"Training": (['a'], {})}
+    assert ['a'] == generator._get_foreign_keys(fkd, ['Training'])
+
+    fkd = {"Training": {'hallo'}}
+    with pytest.raises(ValueError, match=r"A foreign key definition is missing for path:\n\[\]\n{'Training': \{'hallo'\}\}"):
+        generator._get_foreign_keys(fkd, ['Training'])
+
+    fkd = {"Training": (['a'], {'b': ['c']})}
+    assert ['c'] == generator._get_foreign_keys(fkd, ['Training', 'b'])
+
+    with pytest.raises(ValueError, match=r"A foreign key definition is missing for.*"):
+        generator._get_foreign_keys({}, ['Training'])
+
+
+def test_get_max_path_length():
+    assert 4 == XLSXTemplateGenerator._get_max_path_length({'a': (1, [1, 2, 3]),
+                                                            'b': (2, [1, 2, 3, 4])})
+
+
+def test_template_generator():
+    generator = XLSXTemplateGenerator()
+    with open(rfp("model_schema.json")) as sfi:
+        schema = json.load(sfi)
+    path = os.path.join(tempfile.mkdtemp(), 'test.xlsx')
+    assert not os.path.exists(path)
+    generator.generate(schema=schema, foreign_keys={'Training': (['date', 'url'], {})}, filepath=path)
+    assert os.path.exists(path)
+    generated = load_workbook(path)  # workbook can be read
+    example = load_workbook(rfp("example_template.xlsx"))
+    assert generated.sheetnames == example.sheetnames
+    for sheetname in example.sheetnames:
+        gen_sheet = generated[sheetname]
+        ex_sheet = example[sheetname]
+        for irow, (erow, grow) in enumerate(zip(ex_sheet.iter_rows(), gen_sheet.iter_rows())):
+            for icol, (ecol, gcol) in enumerate(zip(erow, grow)):
+                cell = gen_sheet.cell(irow+1, icol+1)
+                assert ecol.value == gcol.value, f"Sheet: {sheetname}, cell: {cell.coordinate}"
+
+    # test some hidden
+    ws = generated.active
+    assert ws.row_dimensions[1].hidden is True
+    assert ws.column_dimensions['A'].hidden is True
+
+
+
+
+
+    rp = '/home/henrik/CaosDB/management/external/dimr/eingabemaske/django/laforms/settings/DevelSchema.json'
+    with open(rp) as sfi:
+        schema = json.load(sfi)
+    generator.generate(schema=schema,
+                       foreign_keys={},
+                       filepath=path)
+    os.system(f'libreoffice {path}')
+
+    # TODO test colisions of sheet or colnames
+    # TODO test escaping of values
+
+    # TODO finish enum example