Skip to content

pyCEURsemantify API Documentation

Created on 2026-10-01

@author: wf

ceursemantify_cmd

Created on 2026-10-01

@author: wf

CeurSemantifyCmd

Bases: BaseCmd

command line interface of pyCEURsemantify: generate volume pages, rebuild year pages, serve the endpoint

Source code in ceursemantify/ceursemantify_cmd.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
class CeurSemantifyCmd(BaseCmd):
    """
    command line interface of pyCEURsemantify:
    generate volume pages, rebuild year pages, serve the endpoint
    """

    def __init__(self):
        """
        constructor
        """
        super().__init__(Version())

    def add_arguments(self, parser: ArgumentParser) -> None:
        """
        add the arguments to the given parser

        Args:
            parser: the argument parser
        """
        super().add_arguments(parser)
        parser.add_argument(
            "volumes",
            nargs="*",
            help="volume numbers and ranges to generate, e.g. 3889 3887-3912",
        )
        parser.add_argument(
            "-y",
            "--year",
            type=int,
            action="append",
            dest="years",
            help="rebuild the page of the given year from the volumes present (can be given several times)",
        )
        parser.add_argument(
            "--regenerate",
            action="store_true",
            help="with --year: fetch and write the volumes present for the year again before rebuilding its page",
        )
        parser.add_argument(
            "--embed",
            nargs="+",
            metavar="PATH",
            help="embed the JSON-LD into the given volume pages of the proceedings chairs (index.html files or"
            " volume directories) and write index.jsonld next to them",
        )
        parser.add_argument(
            "--generate",
            nargs="+",
            metavar="PATH",
            help="generate the volume and paper pages from the given local volume pages (index.html files or"
            " volume directories, an embedded page is read from its index.jsonld) instead of ceur-spt",
        )
        parser.add_argument(
            "--in-place",
            action="store_true",
            help="with --generate: write the volume page, its index.jsonld and the paper pages into the volume"
            " directory itself instead of the year tree of the output directory; the volume page is overwritten",
        )
        parser.add_argument(
            "--yaml",
            action="store_true",
            help="with --embed: also write index.yaml, the human-editable record of the volume, next to index.jsonld;"
            " with --generate: read the volume from its index.yaml instead of its page or index.jsonld",
        )
        parser.add_argument("--progress", action="store_true", help="show a progress bar while generating volumes")
        parser.add_argument(
            "--serve",
            action="store_true",
            help="load all JSON-LD files into memory and serve them as SPARQL endpoint",
        )
        parser.add_argument("--host", default="127.0.0.1", help="host of the endpoint (default: %(default)s)")
        parser.add_argument("--port", type=int, default=9987, help="port of the endpoint (default: %(default)s)")
        parser.add_argument(
            "-o",
            "--output",
            default=".",
            help="directory that holds the year directories (default: current directory)",
        )
        parser.add_argument("--config", help="site configuration yaml file (default: the packaged site.yaml)")

    def show_report(self, title: str, report: SiteReport, args: Namespace) -> None:
        """
        show the given report: problems always, written files if verbose

        Args:
            title: what the report is about
            report: the report
            args: the parsed arguments
        """
        for problem in report.problems:
            print(problem, file=sys.stderr)
        if args.verbose:
            for path in report.written:
                print(path)
        if not args.quiet:
            print(f"{title}: {len(report.written)} files, {len(report.problems)} problems, {report.seconds:.1f} s")

    def handle_args(self, args: Namespace) -> bool:
        """
        handle the parsed arguments

        Args:
            args: the parsed arguments

        Returns:
            bool: True if the arguments were handled
        """
        handled = super().handle_args(args)
        if not handled:
            config = SiteConfig.of_yaml(args.config)
            generator = Generator.of_output(args.output, config)
            if args.embed:
                report = generator.embed_volumes(args.embed, progress=args.progress, yaml=args.yaml)
                self.show_report(f"{len(args.embed)} volume pages", report, args)
                handled = True
            if args.generate:
                report = generator.generate_paths(
                    args.generate, progress=args.progress, in_place=args.in_place, yaml=args.yaml
                )
                self.show_report(f"{len(args.generate)} volume pages", report, args)
                handled = True
            if args.volumes:
                numbers = VolumeRanges.numbers(args.volumes)
                report = generator.generate_volumes(numbers, progress=args.progress)
                self.show_report(f"{len(numbers)} volumes", report, args)
                handled = True
            for year in args.years or []:
                if args.regenerate:
                    report = generator.regenerate_year(year, progress=args.progress)
                else:
                    report = generator.rebuild_year(year)
                self.show_report(f"year {year}", report, args)
                handled = True
            if args.serve:
                endpoint = SptEndpoint(generator.site)
                load_report = endpoint.load()
                if not args.quiet:
                    print(
                        f"endpoint: {load_report.files} files, {load_report.triples} triples, "
                        f"{load_report.seconds:.1f} s, http://{args.host}:{args.port}/npq/{endpoint.name}"
                    )
                endpoint.serve(host=args.host, port=args.port)
                handled = True
        return handled

__init__()

constructor

Source code in ceursemantify/ceursemantify_cmd.py
43
44
45
46
47
def __init__(self):
    """
    constructor
    """
    super().__init__(Version())

add_arguments(parser)

add the arguments to the given parser

Parameters:

Name Type Description Default
parser ArgumentParser

the argument parser

required
Source code in ceursemantify/ceursemantify_cmd.py
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def add_arguments(self, parser: ArgumentParser) -> None:
    """
    add the arguments to the given parser

    Args:
        parser: the argument parser
    """
    super().add_arguments(parser)
    parser.add_argument(
        "volumes",
        nargs="*",
        help="volume numbers and ranges to generate, e.g. 3889 3887-3912",
    )
    parser.add_argument(
        "-y",
        "--year",
        type=int,
        action="append",
        dest="years",
        help="rebuild the page of the given year from the volumes present (can be given several times)",
    )
    parser.add_argument(
        "--regenerate",
        action="store_true",
        help="with --year: fetch and write the volumes present for the year again before rebuilding its page",
    )
    parser.add_argument(
        "--embed",
        nargs="+",
        metavar="PATH",
        help="embed the JSON-LD into the given volume pages of the proceedings chairs (index.html files or"
        " volume directories) and write index.jsonld next to them",
    )
    parser.add_argument(
        "--generate",
        nargs="+",
        metavar="PATH",
        help="generate the volume and paper pages from the given local volume pages (index.html files or"
        " volume directories, an embedded page is read from its index.jsonld) instead of ceur-spt",
    )
    parser.add_argument(
        "--in-place",
        action="store_true",
        help="with --generate: write the volume page, its index.jsonld and the paper pages into the volume"
        " directory itself instead of the year tree of the output directory; the volume page is overwritten",
    )
    parser.add_argument(
        "--yaml",
        action="store_true",
        help="with --embed: also write index.yaml, the human-editable record of the volume, next to index.jsonld;"
        " with --generate: read the volume from its index.yaml instead of its page or index.jsonld",
    )
    parser.add_argument("--progress", action="store_true", help="show a progress bar while generating volumes")
    parser.add_argument(
        "--serve",
        action="store_true",
        help="load all JSON-LD files into memory and serve them as SPARQL endpoint",
    )
    parser.add_argument("--host", default="127.0.0.1", help="host of the endpoint (default: %(default)s)")
    parser.add_argument("--port", type=int, default=9987, help="port of the endpoint (default: %(default)s)")
    parser.add_argument(
        "-o",
        "--output",
        default=".",
        help="directory that holds the year directories (default: current directory)",
    )
    parser.add_argument("--config", help="site configuration yaml file (default: the packaged site.yaml)")

handle_args(args)

handle the parsed arguments

Parameters:

Name Type Description Default
args Namespace

the parsed arguments

required

Returns:

Name Type Description
bool bool

True if the arguments were handled

Source code in ceursemantify/ceursemantify_cmd.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def handle_args(self, args: Namespace) -> bool:
    """
    handle the parsed arguments

    Args:
        args: the parsed arguments

    Returns:
        bool: True if the arguments were handled
    """
    handled = super().handle_args(args)
    if not handled:
        config = SiteConfig.of_yaml(args.config)
        generator = Generator.of_output(args.output, config)
        if args.embed:
            report = generator.embed_volumes(args.embed, progress=args.progress, yaml=args.yaml)
            self.show_report(f"{len(args.embed)} volume pages", report, args)
            handled = True
        if args.generate:
            report = generator.generate_paths(
                args.generate, progress=args.progress, in_place=args.in_place, yaml=args.yaml
            )
            self.show_report(f"{len(args.generate)} volume pages", report, args)
            handled = True
        if args.volumes:
            numbers = VolumeRanges.numbers(args.volumes)
            report = generator.generate_volumes(numbers, progress=args.progress)
            self.show_report(f"{len(numbers)} volumes", report, args)
            handled = True
        for year in args.years or []:
            if args.regenerate:
                report = generator.regenerate_year(year, progress=args.progress)
            else:
                report = generator.rebuild_year(year)
            self.show_report(f"year {year}", report, args)
            handled = True
        if args.serve:
            endpoint = SptEndpoint(generator.site)
            load_report = endpoint.load()
            if not args.quiet:
                print(
                    f"endpoint: {load_report.files} files, {load_report.triples} triples, "
                    f"{load_report.seconds:.1f} s, http://{args.host}:{args.port}/npq/{endpoint.name}"
                )
            endpoint.serve(host=args.host, port=args.port)
            handled = True
    return handled

show_report(title, report, args)

show the given report: problems always, written files if verbose

Parameters:

Name Type Description Default
title str

what the report is about

required
report SiteReport

the report

required
args Namespace

the parsed arguments

required
Source code in ceursemantify/ceursemantify_cmd.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def show_report(self, title: str, report: SiteReport, args: Namespace) -> None:
    """
    show the given report: problems always, written files if verbose

    Args:
        title: what the report is about
        report: the report
        args: the parsed arguments
    """
    for problem in report.problems:
        print(problem, file=sys.stderr)
    if args.verbose:
        for path in report.written:
            print(path)
    if not args.quiet:
        print(f"{title}: {len(report.written)} files, {len(report.problems)} problems, {report.seconds:.1f} s")

Version dataclass

Version information for pyCEURsemantify

Source code in ceursemantify/ceursemantify_cmd.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@dataclass
class Version:
    """
    Version information for pyCEURsemantify
    """

    name: str = "pyCEURsemantify"
    version: str = ceursemantify.__version__
    date: str = "2026-10-01"
    updated: str = "2026-10-27"
    description: str = "Semantification of the CEUR-WS publication workflow"
    authors: str = "Wolfgang Fahl"
    doc_url: str = "https://github.com/ceurws/semantify"
    chat_url: str = "https://github.com/ceurws/semantify/discussions"
    cm_url: str = "https://github.com/ceurws/semantify"

main(argv=None)

command line entry point

Parameters:

Name Type Description Default
argv Optional[List[str]]

the command line arguments

None

Returns:

Name Type Description
int int

the exit code

Source code in ceursemantify/ceursemantify_cmd.py
183
184
185
186
187
188
189
190
191
192
193
194
195
def main(argv: Optional[List[str]] = None) -> int:
    """
    command line entry point

    Args:
        argv: the command line arguments

    Returns:
        int: the exit code
    """
    cmd = CeurSemantifyCmd()
    exit_code = cmd.run(argv)
    return exit_code

convert

Created on 2026-10-01

@author: wf

Convert

conversions of source values to the values of the research model

Each conversion is named in resources/ceurspt_mapping.yaml and raises a ValueError for a value it can not convert.

Source code in ceursemantify/convert.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
class Convert:
    """
    conversions of source values to the values of the research model

    Each conversion is named in resources/ceurspt_mapping.yaml and raises a
    ValueError for a value it can not convert.
    """

    @classmethod
    def text(cls, value: Any) -> str:
        """
        normalize whitespace: line breaks and runs of blanks become one blank

        Args:
            value: the source text

        Returns:
            str: the single line text
        """
        text = " ".join(str(value).split())
        return text

    @classmethod
    def integer(cls, value: Any) -> int:
        """
        convert the given value to an integer

        Args:
            value: the source value

        Returns:
            int: the integer
        """
        integer = int(value)
        return integer

    @classmethod
    def volume_number(cls, value: Any) -> int:
        """
        get the volume number of a volume identifier such as Vol-3889

        Args:
            value: the volume identifier

        Returns:
            int: the volume number
        """
        match = re.fullmatch(r"Vol[-.]?(\d+)", str(value).strip())
        if not match:
            raise ValueError(f"not a volume identifier: {value}")
        volume_number = int(match.group(1))
        return volume_number

    @classmethod
    def iso_date(cls, value: Any) -> str:
        """
        check that the given value is an ISO date

        Args:
            value: the source date

        Returns:
            str: the date as YYYY-MM-DD
        """
        iso_date = date.fromisoformat(str(value).strip()).isoformat()
        return iso_date

    @classmethod
    def last_path_parts(cls, value: Any, marker: str) -> str:
        """
        get the part of the path of an url that follows the given marker

        Args:
            value: the url or an identifier without url
            marker: the path segment the identifier follows e.g. /pid/

        Returns:
            str: the identifier
        """
        identifier = str(value).strip()
        path = urlparse(identifier).path
        if marker in path:
            identifier = path.split(marker, 1)[1]
        return identifier

    @classmethod
    def wikidata_id(cls, value: Any) -> str:
        """
        get the Q-identifier of a Wikidata entity url or identifier

        Args:
            value: the entity url or Q-identifier

        Returns:
            str: the Q-identifier
        """
        wikidata_id = cls.last_path_parts(value, "/entity/")
        if not re.fullmatch(r"Q\d+", wikidata_id):
            raise ValueError(f"not a Wikidata identifier: {value}")
        return wikidata_id

    @classmethod
    def dblp_person_id(cls, value: Any) -> str:
        """
        get the dblp person identifier of a dblp person url

        Args:
            value: the url e.g. https://dblp.org/pid/32/7870

        Returns:
            str: the identifier e.g. 32/7870
        """
        dblp_person_id = cls.last_path_parts(value, "/pid/")
        return dblp_person_id

    @classmethod
    def dblp_record_id(cls, value: Any) -> str:
        """
        get the dblp publication identifier of a dblp record url

        Args:
            value: the url e.g. https://dblp.org/rec/conf/semtab/X24

        Returns:
            str: the identifier e.g. conf/semtab/X24
        """
        dblp_record_id = cls.last_path_parts(value, "/rec/")
        return dblp_record_id

    @classmethod
    def file_stem(cls, value: Any) -> str:
        """
        get the file name without extension of an url

        Args:
            value: the url e.g. https://ceur-ws.org/Vol-3889/paper0.pdf

        Returns:
            str: the stem e.g. paper0
        """
        path = urlparse(str(value)).path
        file_stem = os.path.splitext(os.path.basename(path))[0]
        if not file_stem:
            raise ValueError(f"no file name in: {value}")
        return file_stem

    @classmethod
    def page_part(cls, value: Any, index: int) -> Optional[str]:
        """
        get the first or last page of a page range such as 1-11

        Args:
            value: the page range or a single page
            index: 0 for the first page, -1 for the last page

        Returns:
            Optional[str]: the page
        """
        parts = [part.strip() for part in re.split(r"[-–—]+", str(value)) if part.strip()]
        if not parts:
            raise ValueError(f"no pages in: {value}")
        page_part = parts[index]
        return page_part

    @classmethod
    def page_start(cls, value: Any) -> Optional[str]:
        """
        get the first page of a page range

        Args:
            value: the page range

        Returns:
            Optional[str]: the first page
        """
        page_start = cls.page_part(value, 0)
        return page_start

    @classmethod
    def page_end(cls, value: Any) -> Optional[str]:
        """
        get the last page of a page range

        Args:
            value: the page range

        Returns:
            Optional[str]: the last page
        """
        page_end = cls.page_part(value, -1)
        return page_end

dblp_person_id(value) classmethod

get the dblp person identifier of a dblp person url

Parameters:

Name Type Description Default
value Any

the url e.g. https://dblp.org/pid/32/7870

required

Returns:

Name Type Description
str str

the identifier e.g. 32/7870

Source code in ceursemantify/convert.py
115
116
117
118
119
120
121
122
123
124
125
126
127
@classmethod
def dblp_person_id(cls, value: Any) -> str:
    """
    get the dblp person identifier of a dblp person url

    Args:
        value: the url e.g. https://dblp.org/pid/32/7870

    Returns:
        str: the identifier e.g. 32/7870
    """
    dblp_person_id = cls.last_path_parts(value, "/pid/")
    return dblp_person_id

dblp_record_id(value) classmethod

get the dblp publication identifier of a dblp record url

Parameters:

Name Type Description Default
value Any

the url e.g. https://dblp.org/rec/conf/semtab/X24

required

Returns:

Name Type Description
str str

the identifier e.g. conf/semtab/X24

Source code in ceursemantify/convert.py
129
130
131
132
133
134
135
136
137
138
139
140
141
@classmethod
def dblp_record_id(cls, value: Any) -> str:
    """
    get the dblp publication identifier of a dblp record url

    Args:
        value: the url e.g. https://dblp.org/rec/conf/semtab/X24

    Returns:
        str: the identifier e.g. conf/semtab/X24
    """
    dblp_record_id = cls.last_path_parts(value, "/rec/")
    return dblp_record_id

file_stem(value) classmethod

get the file name without extension of an url

Parameters:

Name Type Description Default
value Any

the url e.g. https://ceur-ws.org/Vol-3889/paper0.pdf

required

Returns:

Name Type Description
str str

the stem e.g. paper0

Source code in ceursemantify/convert.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
@classmethod
def file_stem(cls, value: Any) -> str:
    """
    get the file name without extension of an url

    Args:
        value: the url e.g. https://ceur-ws.org/Vol-3889/paper0.pdf

    Returns:
        str: the stem e.g. paper0
    """
    path = urlparse(str(value)).path
    file_stem = os.path.splitext(os.path.basename(path))[0]
    if not file_stem:
        raise ValueError(f"no file name in: {value}")
    return file_stem

integer(value) classmethod

convert the given value to an integer

Parameters:

Name Type Description Default
value Any

the source value

required

Returns:

Name Type Description
int int

the integer

Source code in ceursemantify/convert.py
36
37
38
39
40
41
42
43
44
45
46
47
48
@classmethod
def integer(cls, value: Any) -> int:
    """
    convert the given value to an integer

    Args:
        value: the source value

    Returns:
        int: the integer
    """
    integer = int(value)
    return integer

iso_date(value) classmethod

check that the given value is an ISO date

Parameters:

Name Type Description Default
value Any

the source date

required

Returns:

Name Type Description
str str

the date as YYYY-MM-DD

Source code in ceursemantify/convert.py
67
68
69
70
71
72
73
74
75
76
77
78
79
@classmethod
def iso_date(cls, value: Any) -> str:
    """
    check that the given value is an ISO date

    Args:
        value: the source date

    Returns:
        str: the date as YYYY-MM-DD
    """
    iso_date = date.fromisoformat(str(value).strip()).isoformat()
    return iso_date

last_path_parts(value, marker) classmethod

get the part of the path of an url that follows the given marker

Parameters:

Name Type Description Default
value Any

the url or an identifier without url

required
marker str

the path segment the identifier follows e.g. /pid/

required

Returns:

Name Type Description
str str

the identifier

Source code in ceursemantify/convert.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@classmethod
def last_path_parts(cls, value: Any, marker: str) -> str:
    """
    get the part of the path of an url that follows the given marker

    Args:
        value: the url or an identifier without url
        marker: the path segment the identifier follows e.g. /pid/

    Returns:
        str: the identifier
    """
    identifier = str(value).strip()
    path = urlparse(identifier).path
    if marker in path:
        identifier = path.split(marker, 1)[1]
    return identifier

page_end(value) classmethod

get the last page of a page range

Parameters:

Name Type Description Default
value Any

the page range

required

Returns:

Type Description
Optional[str]

Optional[str]: the last page

Source code in ceursemantify/convert.py
192
193
194
195
196
197
198
199
200
201
202
203
204
@classmethod
def page_end(cls, value: Any) -> Optional[str]:
    """
    get the last page of a page range

    Args:
        value: the page range

    Returns:
        Optional[str]: the last page
    """
    page_end = cls.page_part(value, -1)
    return page_end

page_part(value, index) classmethod

get the first or last page of a page range such as 1-11

Parameters:

Name Type Description Default
value Any

the page range or a single page

required
index int

0 for the first page, -1 for the last page

required

Returns:

Type Description
Optional[str]

Optional[str]: the page

Source code in ceursemantify/convert.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
@classmethod
def page_part(cls, value: Any, index: int) -> Optional[str]:
    """
    get the first or last page of a page range such as 1-11

    Args:
        value: the page range or a single page
        index: 0 for the first page, -1 for the last page

    Returns:
        Optional[str]: the page
    """
    parts = [part.strip() for part in re.split(r"[-–—]+", str(value)) if part.strip()]
    if not parts:
        raise ValueError(f"no pages in: {value}")
    page_part = parts[index]
    return page_part

page_start(value) classmethod

get the first page of a page range

Parameters:

Name Type Description Default
value Any

the page range

required

Returns:

Type Description
Optional[str]

Optional[str]: the first page

Source code in ceursemantify/convert.py
178
179
180
181
182
183
184
185
186
187
188
189
190
@classmethod
def page_start(cls, value: Any) -> Optional[str]:
    """
    get the first page of a page range

    Args:
        value: the page range

    Returns:
        Optional[str]: the first page
    """
    page_start = cls.page_part(value, 0)
    return page_start

text(value) classmethod

normalize whitespace: line breaks and runs of blanks become one blank

Parameters:

Name Type Description Default
value Any

the source text

required

Returns:

Name Type Description
str str

the single line text

Source code in ceursemantify/convert.py
22
23
24
25
26
27
28
29
30
31
32
33
34
@classmethod
def text(cls, value: Any) -> str:
    """
    normalize whitespace: line breaks and runs of blanks become one blank

    Args:
        value: the source text

    Returns:
        str: the single line text
    """
    text = " ".join(str(value).split())
    return text

volume_number(value) classmethod

get the volume number of a volume identifier such as Vol-3889

Parameters:

Name Type Description Default
value Any

the volume identifier

required

Returns:

Name Type Description
int int

the volume number

Source code in ceursemantify/convert.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@classmethod
def volume_number(cls, value: Any) -> int:
    """
    get the volume number of a volume identifier such as Vol-3889

    Args:
        value: the volume identifier

    Returns:
        int: the volume number
    """
    match = re.fullmatch(r"Vol[-.]?(\d+)", str(value).strip())
    if not match:
        raise ValueError(f"not a volume identifier: {value}")
    volume_number = int(match.group(1))
    return volume_number

wikidata_id(value) classmethod

get the Q-identifier of a Wikidata entity url or identifier

Parameters:

Name Type Description Default
value Any

the entity url or Q-identifier

required

Returns:

Name Type Description
str str

the Q-identifier

Source code in ceursemantify/convert.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
@classmethod
def wikidata_id(cls, value: Any) -> str:
    """
    get the Q-identifier of a Wikidata entity url or identifier

    Args:
        value: the entity url or Q-identifier

    Returns:
        str: the Q-identifier
    """
    wikidata_id = cls.last_path_parts(value, "/entity/")
    if not re.fullmatch(r"Q\d+", wikidata_id):
        raise ValueError(f"not a Wikidata identifier: {value}")
    return wikidata_id

early_volume_html

Created on 2026-10-18

Input mapping of the early volume pages without CEUR marks: free form html with the papers as links in list items, table rows or paragraphs, partly as PostScript only, see https://github.com/ceurws/semantify/issues/26 and https://github.com/ceurws/semantify/issues/39

@author: wf

EarlyVolumeHtml

Bases: VolumeHtml

an early volume page without the CEUR marks of the volume template

Source code in ceursemantify/early_volume_html.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
class EarlyVolumeHtml(VolumeHtml):
    """
    an early volume page without the CEUR marks of the volume template
    """

    # longest first so that .ps.gz is not taken as .gz
    document_extensions = [".ps.gz", ".pdf", ".ps"]
    # the elements that hold one entry of the table of contents
    entry_elements = ["li", "tr", "p", "dd", "dt"]

    def document_of(self, href: Optional[str]) -> Optional[Tuple[str, str]]:
        """
        get the stem and extension of a link to a document of the volume itself

        Args:
            href: the href of the link

        Returns:
            Optional[Tuple[str, str]]: the stem and the extension, None for
                other links and for documents outside the volume
        """
        document = None
        if href:
            href = href.strip()
            if not urlparse(href).scheme and "/" not in href.strip("./"):
                name = posixpath.basename(href)
                for extension in self.document_extensions:
                    if name.lower().endswith(extension):
                        document = (name[: -len(extension)], extension)
                        break
        return document

    def title_of(self, link: Tag) -> Optional[str]:
        """
        get the title of the paper the given link belongs to

        Args:
            link: the link to the document of the paper

        Returns:
            Optional[str]: the text of the entry when it holds only this paper,
                the link text otherwise
        """
        entry = link.find_parent(self.entry_elements)
        title = None
        if entry is not None:
            stems = {self.document_of(a.get("href"))[0] for a in entry.find_all("a") if self.document_of(a.get("href"))}
            if len(stems) == 1:
                title = Convert.text(entry.get_text()) or None
        if title is None:
            title = Convert.text(link.get_text()) or None
        return title

    def papers(self, url: str, result: MappingResult) -> List[Paper]:
        """
        get the papers of the volume: every document of the volume itself
        the page links, one paper per file stem in the order of the page

        Args:
            url: the url of the volume
            result: the mapping result to report issues to

        Returns:
            List[Paper]: the papers, the pdfUrl is the official PDF also when
                only a PostScript file exists, see https://github.com/ceurws/semantify/issues/39
        """
        links: Dict[str, Tag] = {}
        extensions: Dict[str, set] = {}
        for link in self.soup.find_all("a"):
            document = self.document_of(link.get("href"))
            if document is not None:
                stem, extension = document
                links.setdefault(stem, link)
                extensions.setdefault(stem, set()).add(extension)
        papers = []
        for stem, link in links.items():
            title = self.title_of(link)
            if title is None:
                result.issues.append(MappingIssue("Paper", stem, "no title"))
            if ".pdf" not in extensions[stem]:
                result.issues.append(MappingIssue("Paper", stem, "PostScript only"))
            paper = Paper(id=stem, title=title, pdfUrl=urljoin(url, f"{stem}.pdf"), authors=[])
            paper.publish_in(url)
            papers.append(paper)
        return papers

    def volume_number_of(self, result: MappingResult) -> Optional[int]:
        """
        get the volume number from the Vol-N of the path, the page has no CEURVOLNR
        see https://github.com/ceurws/semantify/issues/37
        """
        return self.volume_number

document_of(href)

get the stem and extension of a link to a document of the volume itself

Parameters:

Name Type Description Default
href Optional[str]

the href of the link

required

Returns:

Type Description
Optional[Tuple[str, str]]

Optional[Tuple[str, str]]: the stem and the extension, None for other links and for documents outside the volume

Source code in ceursemantify/early_volume_html.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def document_of(self, href: Optional[str]) -> Optional[Tuple[str, str]]:
    """
    get the stem and extension of a link to a document of the volume itself

    Args:
        href: the href of the link

    Returns:
        Optional[Tuple[str, str]]: the stem and the extension, None for
            other links and for documents outside the volume
    """
    document = None
    if href:
        href = href.strip()
        if not urlparse(href).scheme and "/" not in href.strip("./"):
            name = posixpath.basename(href)
            for extension in self.document_extensions:
                if name.lower().endswith(extension):
                    document = (name[: -len(extension)], extension)
                    break
    return document

papers(url, result)

get the papers of the volume: every document of the volume itself the page links, one paper per file stem in the order of the page

Parameters:

Name Type Description Default
url str

the url of the volume

required
result MappingResult

the mapping result to report issues to

required

Returns:

Type Description
List[Paper]

List[Paper]: the papers, the pdfUrl is the official PDF also when only a PostScript file exists, see https://github.com/ceurws/semantify/issues/39

Source code in ceursemantify/early_volume_html.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def papers(self, url: str, result: MappingResult) -> List[Paper]:
    """
    get the papers of the volume: every document of the volume itself
    the page links, one paper per file stem in the order of the page

    Args:
        url: the url of the volume
        result: the mapping result to report issues to

    Returns:
        List[Paper]: the papers, the pdfUrl is the official PDF also when
            only a PostScript file exists, see https://github.com/ceurws/semantify/issues/39
    """
    links: Dict[str, Tag] = {}
    extensions: Dict[str, set] = {}
    for link in self.soup.find_all("a"):
        document = self.document_of(link.get("href"))
        if document is not None:
            stem, extension = document
            links.setdefault(stem, link)
            extensions.setdefault(stem, set()).add(extension)
    papers = []
    for stem, link in links.items():
        title = self.title_of(link)
        if title is None:
            result.issues.append(MappingIssue("Paper", stem, "no title"))
        if ".pdf" not in extensions[stem]:
            result.issues.append(MappingIssue("Paper", stem, "PostScript only"))
        paper = Paper(id=stem, title=title, pdfUrl=urljoin(url, f"{stem}.pdf"), authors=[])
        paper.publish_in(url)
        papers.append(paper)
    return papers

title_of(link)

get the title of the paper the given link belongs to

Parameters:

Name Type Description Default
link Tag

the link to the document of the paper

required

Returns:

Type Description
Optional[str]

Optional[str]: the text of the entry when it holds only this paper, the link text otherwise

Source code in ceursemantify/early_volume_html.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def title_of(self, link: Tag) -> Optional[str]:
    """
    get the title of the paper the given link belongs to

    Args:
        link: the link to the document of the paper

    Returns:
        Optional[str]: the text of the entry when it holds only this paper,
            the link text otherwise
    """
    entry = link.find_parent(self.entry_elements)
    title = None
    if entry is not None:
        stems = {self.document_of(a.get("href"))[0] for a in entry.find_all("a") if self.document_of(a.get("href"))}
        if len(stems) == 1:
            title = Convert.text(entry.get_text()) or None
    if title is None:
        title = Convert.text(link.get_text()) or None
    return title

volume_number_of(result)

get the volume number from the Vol-N of the path, the page has no CEURVOLNR see https://github.com/ceurws/semantify/issues/37

Source code in ceursemantify/early_volume_html.py
110
111
112
113
114
115
def volume_number_of(self, result: MappingResult) -> Optional[int]:
    """
    get the volume number from the Vol-N of the path, the page has no CEURVOLNR
    see https://github.com/ceurws/semantify/issues/37
    """
    return self.volume_number

embed

Created on 2026-10-13

@author: wf

Embedder

embed the JSON-LD into the volume page of the proceedings chair: the state comment before the html element, the alternate link and the fence in the head; the markup of the chair is otherwise untouched

Source code in ceursemantify/embed.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class Embedder:
    """
    embed the JSON-LD into the volume page of the proceedings chair: the
    state comment before the html element, the alternate link and the fence
    in the head; the markup of the chair is otherwise untouched
    """

    alternate_link = '<link rel="alternate" type="application/ld+json" href="index.jsonld">'
    state_pattern = re.compile(r"<!-- CEURSTATE=[^\n]*? -->\n?")
    fence_pattern = re.compile(rf"<!--\n{Fence.fence}json-ld\n.*?\n{Fence.fence}\n-->\n?", re.DOTALL)
    link_pattern = re.compile(re.escape(alternate_link) + r"\n?")
    # the state comment goes after the doctype, before the html element otherwise
    doctype_pattern = re.compile(r"<!DOCTYPE[^>]*>\n?", re.IGNORECASE)
    html_pattern = re.compile(r"<html[\s>]", re.IGNORECASE)
    head_pattern = re.compile(r"<head[^>]*>\n?", re.IGNORECASE)

    def __init__(self, state: PageState):
        """
        constructor

        Args:
            state: the state the page announces
        """
        self.state = state

    def strip(self, html_text: str) -> str:
        """
        remove the embedded parts from the given page

        Args:
            html_text: the html of a volume page

        Returns:
            str: the html without state comment, alternate link and fence
        """
        stripped = self.state_pattern.sub("", html_text)
        stripped = self.fence_pattern.sub("", stripped)
        stripped = self.link_pattern.sub("", stripped)
        return stripped

    def embed(self, html_text: str, jsonld_text: str, source: str) -> str:
        """
        embed the given JSON-LD into the given page, replacing an earlier embedding

        Args:
            html_text: the html of a volume page
            jsonld_text: the pretty printed JSON-LD
            source: what the JSON-LD was derived from e.g. the url of the volume

        Returns:
            str: the html with the JSON-LD embedded

        Raises:
            ValueError: if the page has no html or no head element
        """
        stripped = self.strip(html_text)
        html_match = self.html_pattern.search(stripped)
        head_match = self.head_pattern.search(stripped)
        if html_match is None or head_match is None:
            raise ValueError("no html or head element to embed into")
        doctype_match = self.doctype_pattern.match(stripped)
        state_at = doctype_match.end() if doctype_match else html_match.start()
        state_comment = self.state.as_html(source, state="embedded")
        inserted = f"{self.alternate_link}\n{Fence.as_html(jsonld_text)}\n"
        embedded = (
            stripped[:state_at]
            + state_comment
            + "\n"
            + stripped[state_at : head_match.end()]
            + inserted
            + stripped[head_match.end() :]
        )
        return embedded

__init__(state)

constructor

Parameters:

Name Type Description Default
state PageState

the state the page announces

required
Source code in ceursemantify/embed.py
28
29
30
31
32
33
34
35
def __init__(self, state: PageState):
    """
    constructor

    Args:
        state: the state the page announces
    """
    self.state = state

embed(html_text, jsonld_text, source)

embed the given JSON-LD into the given page, replacing an earlier embedding

Parameters:

Name Type Description Default
html_text str

the html of a volume page

required
jsonld_text str

the pretty printed JSON-LD

required
source str

what the JSON-LD was derived from e.g. the url of the volume

required

Returns:

Name Type Description
str str

the html with the JSON-LD embedded

Raises:

Type Description
ValueError

if the page has no html or no head element

Source code in ceursemantify/embed.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def embed(self, html_text: str, jsonld_text: str, source: str) -> str:
    """
    embed the given JSON-LD into the given page, replacing an earlier embedding

    Args:
        html_text: the html of a volume page
        jsonld_text: the pretty printed JSON-LD
        source: what the JSON-LD was derived from e.g. the url of the volume

    Returns:
        str: the html with the JSON-LD embedded

    Raises:
        ValueError: if the page has no html or no head element
    """
    stripped = self.strip(html_text)
    html_match = self.html_pattern.search(stripped)
    head_match = self.head_pattern.search(stripped)
    if html_match is None or head_match is None:
        raise ValueError("no html or head element to embed into")
    doctype_match = self.doctype_pattern.match(stripped)
    state_at = doctype_match.end() if doctype_match else html_match.start()
    state_comment = self.state.as_html(source, state="embedded")
    inserted = f"{self.alternate_link}\n{Fence.as_html(jsonld_text)}\n"
    embedded = (
        stripped[:state_at]
        + state_comment
        + "\n"
        + stripped[state_at : head_match.end()]
        + inserted
        + stripped[head_match.end() :]
    )
    return embedded

strip(html_text)

remove the embedded parts from the given page

Parameters:

Name Type Description Default
html_text str

the html of a volume page

required

Returns:

Name Type Description
str str

the html without state comment, alternate link and fence

Source code in ceursemantify/embed.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def strip(self, html_text: str) -> str:
    """
    remove the embedded parts from the given page

    Args:
        html_text: the html of a volume page

    Returns:
        str: the html without state comment, alternate link and fence
    """
    stripped = self.state_pattern.sub("", html_text)
    stripped = self.fence_pattern.sub("", stripped)
    stripped = self.link_pattern.sub("", stripped)
    return stripped

endpoint

Created on 2026-10-01

@author: wf

LoadReport dataclass

what was loaded into the endpoint

Source code in ceursemantify/endpoint.py
16
17
18
19
20
21
22
23
24
@dataclass
class LoadReport:
    """
    what was loaded into the endpoint
    """

    files: int = 0
    triples: int = 0
    seconds: float = 0.0

SptEndpoint

single point of truth endpoint: the JSON-LD files of the site loaded into memory on startup and served by the NPQ endpoint of pyLoDStorage, see https://cr.bitplan.com/index.php/NPQ-FakeSparql

Source code in ceursemantify/endpoint.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
class SptEndpoint:
    """
    single point of truth endpoint: the JSON-LD files of the site loaded
    into memory on startup and served by the NPQ endpoint of pyLoDStorage,
    see https://cr.bitplan.com/index.php/NPQ-FakeSparql
    """

    def __init__(self, site: Site, name: str = "ceur-ws"):
        """
        constructor

        Args:
            site: the directory tree with the JSON-LD files
            name: the name the graph is served under: /npq/<name>
        """
        self.site = site
        self.name = name
        self.graph = Graph()

    def load(self) -> LoadReport:
        """
        load all JSON-LD files of the site into the graph

        Returns:
            LoadReport: number of files and triples and the time used
        """
        report = LoadReport()
        start = time.perf_counter()
        for path in self.site.jsonld_paths():
            self.graph.parse(path, format="json-ld")
            report.files += 1
        report.triples = len(self.graph)
        report.seconds = time.perf_counter() - start
        return report

    def serve(self, host: str = "127.0.0.1", port: int = 9987) -> None:
        """
        serve the graph over the SPARQL protocol

        Args:
            host: the host to listen on
            port: the port to listen on
        """
        endpoint = NpqEndpoint()
        endpoint.add_graph(self.name, self.graph)
        endpoint.run(host=host, port=port)

__init__(site, name='ceur-ws')

constructor

Parameters:

Name Type Description Default
site Site

the directory tree with the JSON-LD files

required
name str

the name the graph is served under: /npq/

'ceur-ws'
Source code in ceursemantify/endpoint.py
34
35
36
37
38
39
40
41
42
43
44
def __init__(self, site: Site, name: str = "ceur-ws"):
    """
    constructor

    Args:
        site: the directory tree with the JSON-LD files
        name: the name the graph is served under: /npq/<name>
    """
    self.site = site
    self.name = name
    self.graph = Graph()

load()

load all JSON-LD files of the site into the graph

Returns:

Name Type Description
LoadReport LoadReport

number of files and triples and the time used

Source code in ceursemantify/endpoint.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def load(self) -> LoadReport:
    """
    load all JSON-LD files of the site into the graph

    Returns:
        LoadReport: number of files and triples and the time used
    """
    report = LoadReport()
    start = time.perf_counter()
    for path in self.site.jsonld_paths():
        self.graph.parse(path, format="json-ld")
        report.files += 1
    report.triples = len(self.graph)
    report.seconds = time.perf_counter() - start
    return report

serve(host='127.0.0.1', port=9987)

serve the graph over the SPARQL protocol

Parameters:

Name Type Description Default
host str

the host to listen on

'127.0.0.1'
port int

the port to listen on

9987
Source code in ceursemantify/endpoint.py
62
63
64
65
66
67
68
69
70
71
72
def serve(self, host: str = "127.0.0.1", port: int = 9987) -> None:
    """
    serve the graph over the SPARQL protocol

    Args:
        host: the host to listen on
        port: the port to listen on
    """
    endpoint = NpqEndpoint()
    endpoint.add_graph(self.name, self.graph)
    endpoint.run(host=host, port=port)

generator

Created on 2026-10-01

@author: wf

Generator

generate the pages of volumes and years of the site

Source code in ceursemantify/generator.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
class Generator:
    """
    generate the pages of volumes and years of the site
    """

    def __init__(
        self,
        site: Site,
        config: SiteConfig,
        mapper: Mapper,
        jsonld: JsonLd,
        spt: CeurSpt,
        state: Optional[PageState] = None,
    ):
        """
        constructor

        Args:
            site: the directory tree to generate into
            config: the site configuration
            mapper: the mapper of the source records to the research model
            jsonld: the JSON-LD support of the research model
            spt: the service that provides the source records
            state: the state the pages announce, generated today if None
        """
        self.site = site
        self.config = config
        self.mapper = mapper
        self.jsonld = jsonld
        self.spt = spt
        self.state = state if state is not None else PageState.today()

    @classmethod
    def of_output(cls, output_dir: str, config: Optional[SiteConfig] = None) -> "Generator":
        """
        create a generator for the given output directory

        Args:
            output_dir: the directory that holds the year directories
            config: the site configuration, the default configuration if None

        Returns:
            Generator: the generator
        """
        if config is None:
            config = SiteConfig.of_yaml()
        jsonld = JsonLd.of_yaml()
        generator = cls(
            site=Site(output_dir, jsonld),
            config=config,
            mapper=Mapper.of_yaml(),
            jsonld=jsonld,
            spt=CeurSpt(config.spt_url),
        )
        return generator

    def fetch_proceedings(self, number: int, report: SiteReport) -> Optional[Proceedings]:
        """
        fetch the source record of the volume with the given number and map
        it to the research model

        Args:
            number: the volume number
            report: the report to add problems to

        Returns:
            Optional[Proceedings]: the proceedings, None if the record could not be fetched
        """
        proceedings = None
        try:
            record = self.spt.volume_record(number)
            result = self.mapper.map_record(record, "Proceedings")
            proceedings = result.target
            for paper in proceedings.papers:
                paper.publish_in(proceedings.url)
            for issue in result.issues:
                report.problems.append(f"Vol-{number}: {issue}")
        except (OSError, ValueError) as error:
            report.problems.append(f"Vol-{number}: {self.spt.volume_url(number)}: {error}")
        return proceedings

    def read_proceedings(self, path: str, report: SiteReport, yaml: bool = False) -> Optional[Proceedings]:
        """
        read the proceedings from the local volume page at the given path, an
        embedded page from its JSON-LD, see issues 33 and 34; with yaml from
        the index.yaml next to the page, the human-editable record, see issue 59

        Args:
            path: the index.html of the volume or its directory
            report: the report to add problems to
            yaml: if True read the index.yaml next to the page

        Returns:
            Optional[Proceedings]: the proceedings, None if the page could not be read
        """
        if os.path.isdir(path):
            path = os.path.join(path, "index.html")
        proceedings = None
        try:
            if yaml:
                proceedings = Proceedings.load_from_yaml_file(self.yaml_path(path))
            else:
                volume_html = VolumeHtml.of_file(path, self.config.original_url)
                result = volume_html.proceedings()
                proceedings = result.target
                for issue in result.issues:
                    report.problems.append(f"{path}: {issue}")
            for paper in proceedings.papers:
                paper.publish_in(proceedings.url)
        except OSError as error:
            report.problems.append(f"{path}: {error}")
        return proceedings

    @classmethod
    def yaml_path(cls, path: str) -> str:
        """
        get the path of the index.yaml next to the given index.html

        Args:
            path: the path of the index.html

        Returns:
            str: the path of the index.yaml
        """
        yaml_path = os.path.join(os.path.dirname(path), "index.yaml")
        return yaml_path

    def write_volume(self, proceedings: Proceedings, report: SiteReport, volume_dir: Optional[str] = None) -> None:
        """
        write the page and the JSON-LD file of the given proceedings

        Args:
            proceedings: the proceedings, with a year
            report: the report to add the written files to
            volume_dir: the directory to write to, the year tree of the site if None
        """
        in_place = volume_dir is not None
        if volume_dir is None:
            volume_dir = self.site.volume_dir(proceedings)
        page = VolumePage(proceedings, self.config, self.jsonld, self.state, in_place=in_place)
        self.site.write(os.path.join(volume_dir, "index.html"), page.as_html(), report)
        self.site.write(os.path.join(volume_dir, "index.jsonld"), page.jsonld_text() + "\n", report)
        for paper in proceedings.papers:
            if paper.id:
                self.write_paper(proceedings, paper, report, os.path.join(volume_dir, paper.id))

    def write_paper(self, proceedings: Proceedings, paper: Paper, report: SiteReport, paper_dir: str) -> None:
        """
        write the page and the JSON-LD file of the given paper

        Args:
            proceedings: the proceedings the paper is published in
            paper: the paper, with an id
            report: the report to add the written files to
            paper_dir: the directory to write to
        """
        page = PaperPage(paper, proceedings, self.config, self.jsonld, self.state)
        self.site.write(os.path.join(paper_dir, "index.html"), page.as_html(), report)
        self.site.write(os.path.join(paper_dir, "index.jsonld"), page.jsonld_text() + "\n", report)

    def generate_volumes(self, numbers: List[int], progress: bool = False) -> SiteReport:
        """
        generate the pages of the volumes with the given numbers

        A volume that can not be fetched or has no event start date is not
        written and reported as a problem. Year pages are not touched.

        Args:
            numbers: the volume numbers
            progress: if True show a progress bar

        Returns:
            SiteReport: the written files, the problems and the time used
        """
        report = SiteReport()
        start = time.perf_counter()
        for number in tqdm(numbers, desc="volumes", unit="vol", disable=not progress):
            proceedings = self.fetch_proceedings(number, report)
            if proceedings is None:
                continue
            if proceedings.year() is None:
                report.problems.append(f"Vol-{number}: no event start date, the volume has no year")
                continue
            self.write_volume(proceedings, report)
        report.seconds = time.perf_counter() - start
        return report

    def generate_paths(
        self, paths: List[str], progress: bool = False, in_place: bool = False, yaml: bool = False
    ) -> SiteReport:
        """
        generate the pages of the volumes from their local volume pages
        instead of ceur-spt, see issues 33 and 34; the volume pages are not
        touched unless in_place is set, see issue 43: then the volume page,
        its index.jsonld and the paper pages are written into the volume
        directory itself and the year tree of the site is not used

        A volume that can not be read or has no event start date is not
        written and reported as a problem. Year pages are not touched.

        Args:
            paths: the index.html files of the volumes or their directories
            progress: if True show a progress bar
            in_place: if True write into the volume directories of the given pages
            yaml: if True read each volume from its index.yaml, see issue 59

        Returns:
            SiteReport: the written files, the problems and the time used
        """
        report = SiteReport()
        start = time.perf_counter()
        for path in tqdm(paths, desc="volumes", unit="vol", disable=not progress):
            proceedings = self.read_proceedings(path, report, yaml=yaml)
            if proceedings is None:
                continue
            if proceedings.year() is None:
                report.problems.append(f"{path}: no event start date, the volume has no year")
                continue
            volume_dir = None
            if in_place:
                volume_dir = path if os.path.isdir(path) else os.path.dirname(os.path.abspath(path))
            self.write_volume(proceedings, report, volume_dir)
        report.seconds = time.perf_counter() - start
        return report

    def rebuild_year(self, year: int) -> SiteReport:
        """
        rebuild the page of the given year from the volumes present for it

        Args:
            year: the year

        Returns:
            SiteReport: the written file, the problems and the time used
        """
        report = SiteReport()
        start = time.perf_counter()
        proceedings_list = self.site.proceedings_of_year(year)
        if proceedings_list:
            page = YearPage(year, proceedings_list, self.site.years(), self.config, self.state)
            self.site.write(os.path.join(self.site.year_dir(year), "index.html"), page.as_html(), report)
        else:
            report.problems.append(f"{year}: no volumes in {self.site.year_dir(year)}")
        report.seconds = time.perf_counter() - start
        return report

    def regenerate_year(self, year: int, progress: bool = False) -> SiteReport:
        """
        regenerate the given year: fetch and write the volumes present for
        the year again and rebuild the page of the year

        Args:
            year: the year
            progress: if True show a progress bar

        Returns:
            SiteReport: the written files, the problems and the time used
        """
        numbers = self.site.volume_numbers(year)
        report = self.generate_volumes(numbers, progress=progress)
        year_report = self.rebuild_year(year)
        report.written.extend(year_report.written)
        report.problems.extend(year_report.problems)
        report.seconds += year_report.seconds
        return report

    def embed_volume(self, path: str, report: SiteReport, yaml: bool = False) -> None:
        """
        embed the JSON-LD into the volume page of the proceedings chair at
        the given path and write index.jsonld next to it, see issue 28

        The page is rewritten only if the round trip holds: the proceedings
        parsed from the embedded page equal the proceedings parsed from the
        original page.

        Args:
            path: the index.html of the volume or its directory
            report: the report to add the written files and problems to
            yaml: if True also write index.yaml, the human-editable record, see issue 59
        """
        if os.path.isdir(path):
            path = os.path.join(path, "index.html")
        try:
            volume_html = VolumeHtml.of_file(path, self.config.original_url)
        except OSError as error:
            report.problems.append(f"{path}: {error}")
            return
        result = volume_html.proceedings()
        proceedings = result.target
        for issue in result.issues:
            report.problems.append(f"{path}: {issue}")
        jsonld_text = self.jsonld.as_text(proceedings)
        embedder = Embedder(self.state)
        try:
            embedded = embedder.embed(volume_html.text, jsonld_text, proceedings.url or path)
        except ValueError as error:
            report.problems.append(f"{path}: {error}")
            return
        from_embedded = VolumeHtml(embedded, self.config.original_url).proceedings().target
        if from_embedded != proceedings:
            report.problems.append(f"{path}: the embedded page does not parse to the same proceedings")
            return
        self.site.write(os.path.join(os.path.dirname(path), "index.jsonld"), jsonld_text + "\n", report)
        self.site.write(path, embedded, report)
        if yaml:
            yaml_path = self.yaml_path(path)
            proceedings.save_to_yaml_file(yaml_path)
            report.written.append(yaml_path)

    def embed_volumes(self, paths: List[str], progress: bool = False, yaml: bool = False) -> SiteReport:
        """
        embed the JSON-LD into the volume pages at the given paths

        Args:
            paths: the index.html files of the volumes or their directories
            progress: if True show a progress bar
            yaml: if True also write index.yaml per volume, see issue 59

        Returns:
            SiteReport: the written files, the problems and the time used
        """
        report = SiteReport()
        start = time.perf_counter()
        for path in tqdm(paths, desc="volumes", unit="vol", disable=not progress):
            self.embed_volume(path, report, yaml=yaml)
        report.seconds = time.perf_counter() - start
        return report

    def round_trip(self, page_dir: str) -> bool:
        """
        check the round trip of a generated volume or paper: the JSON-LD
        extracted from the fence of index.html equals index.jsonld

        Args:
            page_dir: the directory of the volume or paper

        Returns:
            bool: True if the page has exactly one json-ld fence and the documents are equal
        """
        extractor = Extractor()
        markups = extractor.extract_from_file(os.path.join(page_dir, "index.html"))
        fences = [markup for markup in markups if markup.lang == "json-ld"]
        equal = False
        if len(fences) == 1:
            with open(os.path.join(page_dir, "index.jsonld"), "r", encoding="utf-8") as jsonld_file:
                from_file = json.load(jsonld_file)
            equal = json.loads(fences[0].code) == from_file
        return equal

__init__(site, config, mapper, jsonld, spt, state=None)

constructor

Parameters:

Name Type Description Default
site Site

the directory tree to generate into

required
config SiteConfig

the site configuration

required
mapper Mapper

the mapper of the source records to the research model

required
jsonld JsonLd

the JSON-LD support of the research model

required
spt CeurSpt

the service that provides the source records

required
state Optional[PageState]

the state the pages announce, generated today if None

None
Source code in ceursemantify/generator.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def __init__(
    self,
    site: Site,
    config: SiteConfig,
    mapper: Mapper,
    jsonld: JsonLd,
    spt: CeurSpt,
    state: Optional[PageState] = None,
):
    """
    constructor

    Args:
        site: the directory tree to generate into
        config: the site configuration
        mapper: the mapper of the source records to the research model
        jsonld: the JSON-LD support of the research model
        spt: the service that provides the source records
        state: the state the pages announce, generated today if None
    """
    self.site = site
    self.config = config
    self.mapper = mapper
    self.jsonld = jsonld
    self.spt = spt
    self.state = state if state is not None else PageState.today()

embed_volume(path, report, yaml=False)

embed the JSON-LD into the volume page of the proceedings chair at the given path and write index.jsonld next to it, see issue 28

The page is rewritten only if the round trip holds: the proceedings parsed from the embedded page equal the proceedings parsed from the original page.

Parameters:

Name Type Description Default
path str

the index.html of the volume or its directory

required
report SiteReport

the report to add the written files and problems to

required
yaml bool

if True also write index.yaml, the human-editable record, see issue 59

False
Source code in ceursemantify/generator.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def embed_volume(self, path: str, report: SiteReport, yaml: bool = False) -> None:
    """
    embed the JSON-LD into the volume page of the proceedings chair at
    the given path and write index.jsonld next to it, see issue 28

    The page is rewritten only if the round trip holds: the proceedings
    parsed from the embedded page equal the proceedings parsed from the
    original page.

    Args:
        path: the index.html of the volume or its directory
        report: the report to add the written files and problems to
        yaml: if True also write index.yaml, the human-editable record, see issue 59
    """
    if os.path.isdir(path):
        path = os.path.join(path, "index.html")
    try:
        volume_html = VolumeHtml.of_file(path, self.config.original_url)
    except OSError as error:
        report.problems.append(f"{path}: {error}")
        return
    result = volume_html.proceedings()
    proceedings = result.target
    for issue in result.issues:
        report.problems.append(f"{path}: {issue}")
    jsonld_text = self.jsonld.as_text(proceedings)
    embedder = Embedder(self.state)
    try:
        embedded = embedder.embed(volume_html.text, jsonld_text, proceedings.url or path)
    except ValueError as error:
        report.problems.append(f"{path}: {error}")
        return
    from_embedded = VolumeHtml(embedded, self.config.original_url).proceedings().target
    if from_embedded != proceedings:
        report.problems.append(f"{path}: the embedded page does not parse to the same proceedings")
        return
    self.site.write(os.path.join(os.path.dirname(path), "index.jsonld"), jsonld_text + "\n", report)
    self.site.write(path, embedded, report)
    if yaml:
        yaml_path = self.yaml_path(path)
        proceedings.save_to_yaml_file(yaml_path)
        report.written.append(yaml_path)

embed_volumes(paths, progress=False, yaml=False)

embed the JSON-LD into the volume pages at the given paths

Parameters:

Name Type Description Default
paths List[str]

the index.html files of the volumes or their directories

required
progress bool

if True show a progress bar

False
yaml bool

if True also write index.yaml per volume, see issue 59

False

Returns:

Name Type Description
SiteReport SiteReport

the written files, the problems and the time used

Source code in ceursemantify/generator.py
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def embed_volumes(self, paths: List[str], progress: bool = False, yaml: bool = False) -> SiteReport:
    """
    embed the JSON-LD into the volume pages at the given paths

    Args:
        paths: the index.html files of the volumes or their directories
        progress: if True show a progress bar
        yaml: if True also write index.yaml per volume, see issue 59

    Returns:
        SiteReport: the written files, the problems and the time used
    """
    report = SiteReport()
    start = time.perf_counter()
    for path in tqdm(paths, desc="volumes", unit="vol", disable=not progress):
        self.embed_volume(path, report, yaml=yaml)
    report.seconds = time.perf_counter() - start
    return report

fetch_proceedings(number, report)

fetch the source record of the volume with the given number and map it to the research model

Parameters:

Name Type Description Default
number int

the volume number

required
report SiteReport

the report to add problems to

required

Returns:

Type Description
Optional[Proceedings]

Optional[Proceedings]: the proceedings, None if the record could not be fetched

Source code in ceursemantify/generator.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def fetch_proceedings(self, number: int, report: SiteReport) -> Optional[Proceedings]:
    """
    fetch the source record of the volume with the given number and map
    it to the research model

    Args:
        number: the volume number
        report: the report to add problems to

    Returns:
        Optional[Proceedings]: the proceedings, None if the record could not be fetched
    """
    proceedings = None
    try:
        record = self.spt.volume_record(number)
        result = self.mapper.map_record(record, "Proceedings")
        proceedings = result.target
        for paper in proceedings.papers:
            paper.publish_in(proceedings.url)
        for issue in result.issues:
            report.problems.append(f"Vol-{number}: {issue}")
    except (OSError, ValueError) as error:
        report.problems.append(f"Vol-{number}: {self.spt.volume_url(number)}: {error}")
    return proceedings

generate_paths(paths, progress=False, in_place=False, yaml=False)

generate the pages of the volumes from their local volume pages instead of ceur-spt, see issues 33 and 34; the volume pages are not touched unless in_place is set, see issue 43: then the volume page, its index.jsonld and the paper pages are written into the volume directory itself and the year tree of the site is not used

A volume that can not be read or has no event start date is not written and reported as a problem. Year pages are not touched.

Parameters:

Name Type Description Default
paths List[str]

the index.html files of the volumes or their directories

required
progress bool

if True show a progress bar

False
in_place bool

if True write into the volume directories of the given pages

False
yaml bool

if True read each volume from its index.yaml, see issue 59

False

Returns:

Name Type Description
SiteReport SiteReport

the written files, the problems and the time used

Source code in ceursemantify/generator.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
def generate_paths(
    self, paths: List[str], progress: bool = False, in_place: bool = False, yaml: bool = False
) -> SiteReport:
    """
    generate the pages of the volumes from their local volume pages
    instead of ceur-spt, see issues 33 and 34; the volume pages are not
    touched unless in_place is set, see issue 43: then the volume page,
    its index.jsonld and the paper pages are written into the volume
    directory itself and the year tree of the site is not used

    A volume that can not be read or has no event start date is not
    written and reported as a problem. Year pages are not touched.

    Args:
        paths: the index.html files of the volumes or their directories
        progress: if True show a progress bar
        in_place: if True write into the volume directories of the given pages
        yaml: if True read each volume from its index.yaml, see issue 59

    Returns:
        SiteReport: the written files, the problems and the time used
    """
    report = SiteReport()
    start = time.perf_counter()
    for path in tqdm(paths, desc="volumes", unit="vol", disable=not progress):
        proceedings = self.read_proceedings(path, report, yaml=yaml)
        if proceedings is None:
            continue
        if proceedings.year() is None:
            report.problems.append(f"{path}: no event start date, the volume has no year")
            continue
        volume_dir = None
        if in_place:
            volume_dir = path if os.path.isdir(path) else os.path.dirname(os.path.abspath(path))
        self.write_volume(proceedings, report, volume_dir)
    report.seconds = time.perf_counter() - start
    return report

generate_volumes(numbers, progress=False)

generate the pages of the volumes with the given numbers

A volume that can not be fetched or has no event start date is not written and reported as a problem. Year pages are not touched.

Parameters:

Name Type Description Default
numbers List[int]

the volume numbers

required
progress bool

if True show a progress bar

False

Returns:

Name Type Description
SiteReport SiteReport

the written files, the problems and the time used

Source code in ceursemantify/generator.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def generate_volumes(self, numbers: List[int], progress: bool = False) -> SiteReport:
    """
    generate the pages of the volumes with the given numbers

    A volume that can not be fetched or has no event start date is not
    written and reported as a problem. Year pages are not touched.

    Args:
        numbers: the volume numbers
        progress: if True show a progress bar

    Returns:
        SiteReport: the written files, the problems and the time used
    """
    report = SiteReport()
    start = time.perf_counter()
    for number in tqdm(numbers, desc="volumes", unit="vol", disable=not progress):
        proceedings = self.fetch_proceedings(number, report)
        if proceedings is None:
            continue
        if proceedings.year() is None:
            report.problems.append(f"Vol-{number}: no event start date, the volume has no year")
            continue
        self.write_volume(proceedings, report)
    report.seconds = time.perf_counter() - start
    return report

of_output(output_dir, config=None) classmethod

create a generator for the given output directory

Parameters:

Name Type Description Default
output_dir str

the directory that holds the year directories

required
config Optional[SiteConfig]

the site configuration, the default configuration if None

None

Returns:

Name Type Description
Generator Generator

the generator

Source code in ceursemantify/generator.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@classmethod
def of_output(cls, output_dir: str, config: Optional[SiteConfig] = None) -> "Generator":
    """
    create a generator for the given output directory

    Args:
        output_dir: the directory that holds the year directories
        config: the site configuration, the default configuration if None

    Returns:
        Generator: the generator
    """
    if config is None:
        config = SiteConfig.of_yaml()
    jsonld = JsonLd.of_yaml()
    generator = cls(
        site=Site(output_dir, jsonld),
        config=config,
        mapper=Mapper.of_yaml(),
        jsonld=jsonld,
        spt=CeurSpt(config.spt_url),
    )
    return generator

read_proceedings(path, report, yaml=False)

read the proceedings from the local volume page at the given path, an embedded page from its JSON-LD, see issues 33 and 34; with yaml from the index.yaml next to the page, the human-editable record, see issue 59

Parameters:

Name Type Description Default
path str

the index.html of the volume or its directory

required
report SiteReport

the report to add problems to

required
yaml bool

if True read the index.yaml next to the page

False

Returns:

Type Description
Optional[Proceedings]

Optional[Proceedings]: the proceedings, None if the page could not be read

Source code in ceursemantify/generator.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def read_proceedings(self, path: str, report: SiteReport, yaml: bool = False) -> Optional[Proceedings]:
    """
    read the proceedings from the local volume page at the given path, an
    embedded page from its JSON-LD, see issues 33 and 34; with yaml from
    the index.yaml next to the page, the human-editable record, see issue 59

    Args:
        path: the index.html of the volume or its directory
        report: the report to add problems to
        yaml: if True read the index.yaml next to the page

    Returns:
        Optional[Proceedings]: the proceedings, None if the page could not be read
    """
    if os.path.isdir(path):
        path = os.path.join(path, "index.html")
    proceedings = None
    try:
        if yaml:
            proceedings = Proceedings.load_from_yaml_file(self.yaml_path(path))
        else:
            volume_html = VolumeHtml.of_file(path, self.config.original_url)
            result = volume_html.proceedings()
            proceedings = result.target
            for issue in result.issues:
                report.problems.append(f"{path}: {issue}")
        for paper in proceedings.papers:
            paper.publish_in(proceedings.url)
    except OSError as error:
        report.problems.append(f"{path}: {error}")
    return proceedings

rebuild_year(year)

rebuild the page of the given year from the volumes present for it

Parameters:

Name Type Description Default
year int

the year

required

Returns:

Name Type Description
SiteReport SiteReport

the written file, the problems and the time used

Source code in ceursemantify/generator.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
def rebuild_year(self, year: int) -> SiteReport:
    """
    rebuild the page of the given year from the volumes present for it

    Args:
        year: the year

    Returns:
        SiteReport: the written file, the problems and the time used
    """
    report = SiteReport()
    start = time.perf_counter()
    proceedings_list = self.site.proceedings_of_year(year)
    if proceedings_list:
        page = YearPage(year, proceedings_list, self.site.years(), self.config, self.state)
        self.site.write(os.path.join(self.site.year_dir(year), "index.html"), page.as_html(), report)
    else:
        report.problems.append(f"{year}: no volumes in {self.site.year_dir(year)}")
    report.seconds = time.perf_counter() - start
    return report

regenerate_year(year, progress=False)

regenerate the given year: fetch and write the volumes present for the year again and rebuild the page of the year

Parameters:

Name Type Description Default
year int

the year

required
progress bool

if True show a progress bar

False

Returns:

Name Type Description
SiteReport SiteReport

the written files, the problems and the time used

Source code in ceursemantify/generator.py
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
def regenerate_year(self, year: int, progress: bool = False) -> SiteReport:
    """
    regenerate the given year: fetch and write the volumes present for
    the year again and rebuild the page of the year

    Args:
        year: the year
        progress: if True show a progress bar

    Returns:
        SiteReport: the written files, the problems and the time used
    """
    numbers = self.site.volume_numbers(year)
    report = self.generate_volumes(numbers, progress=progress)
    year_report = self.rebuild_year(year)
    report.written.extend(year_report.written)
    report.problems.extend(year_report.problems)
    report.seconds += year_report.seconds
    return report

round_trip(page_dir)

check the round trip of a generated volume or paper: the JSON-LD extracted from the fence of index.html equals index.jsonld

Parameters:

Name Type Description Default
page_dir str

the directory of the volume or paper

required

Returns:

Name Type Description
bool bool

True if the page has exactly one json-ld fence and the documents are equal

Source code in ceursemantify/generator.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
def round_trip(self, page_dir: str) -> bool:
    """
    check the round trip of a generated volume or paper: the JSON-LD
    extracted from the fence of index.html equals index.jsonld

    Args:
        page_dir: the directory of the volume or paper

    Returns:
        bool: True if the page has exactly one json-ld fence and the documents are equal
    """
    extractor = Extractor()
    markups = extractor.extract_from_file(os.path.join(page_dir, "index.html"))
    fences = [markup for markup in markups if markup.lang == "json-ld"]
    equal = False
    if len(fences) == 1:
        with open(os.path.join(page_dir, "index.jsonld"), "r", encoding="utf-8") as jsonld_file:
            from_file = json.load(jsonld_file)
        equal = json.loads(fences[0].code) == from_file
    return equal

write_paper(proceedings, paper, report, paper_dir)

write the page and the JSON-LD file of the given paper

Parameters:

Name Type Description Default
proceedings Proceedings

the proceedings the paper is published in

required
paper Paper

the paper, with an id

required
report SiteReport

the report to add the written files to

required
paper_dir str

the directory to write to

required
Source code in ceursemantify/generator.py
170
171
172
173
174
175
176
177
178
179
180
181
182
def write_paper(self, proceedings: Proceedings, paper: Paper, report: SiteReport, paper_dir: str) -> None:
    """
    write the page and the JSON-LD file of the given paper

    Args:
        proceedings: the proceedings the paper is published in
        paper: the paper, with an id
        report: the report to add the written files to
        paper_dir: the directory to write to
    """
    page = PaperPage(paper, proceedings, self.config, self.jsonld, self.state)
    self.site.write(os.path.join(paper_dir, "index.html"), page.as_html(), report)
    self.site.write(os.path.join(paper_dir, "index.jsonld"), page.jsonld_text() + "\n", report)

write_volume(proceedings, report, volume_dir=None)

write the page and the JSON-LD file of the given proceedings

Parameters:

Name Type Description Default
proceedings Proceedings

the proceedings, with a year

required
report SiteReport

the report to add the written files to

required
volume_dir Optional[str]

the directory to write to, the year tree of the site if None

None
Source code in ceursemantify/generator.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def write_volume(self, proceedings: Proceedings, report: SiteReport, volume_dir: Optional[str] = None) -> None:
    """
    write the page and the JSON-LD file of the given proceedings

    Args:
        proceedings: the proceedings, with a year
        report: the report to add the written files to
        volume_dir: the directory to write to, the year tree of the site if None
    """
    in_place = volume_dir is not None
    if volume_dir is None:
        volume_dir = self.site.volume_dir(proceedings)
    page = VolumePage(proceedings, self.config, self.jsonld, self.state, in_place=in_place)
    self.site.write(os.path.join(volume_dir, "index.html"), page.as_html(), report)
    self.site.write(os.path.join(volume_dir, "index.jsonld"), page.jsonld_text() + "\n", report)
    for paper in proceedings.papers:
        if paper.id:
            self.write_paper(proceedings, paper, report, os.path.join(volume_dir, paper.id))

yaml_path(path) classmethod

get the path of the index.yaml next to the given index.html

Parameters:

Name Type Description Default
path str

the path of the index.html

required

Returns:

Name Type Description
str str

the path of the index.yaml

Source code in ceursemantify/generator.py
137
138
139
140
141
142
143
144
145
146
147
148
149
@classmethod
def yaml_path(cls, path: str) -> str:
    """
    get the path of the index.yaml next to the given index.html

    Args:
        path: the path of the index.html

    Returns:
        str: the path of the index.yaml
    """
    yaml_path = os.path.join(os.path.dirname(path), "index.yaml")
    return yaml_path

jsonld

Created on 2026-10-01

@author: wf

JsonLd

JSON-LD of the research model according to the context table resources/context.yaml

Source code in ceursemantify/jsonld.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
class JsonLd:
    """
    JSON-LD of the research model according to the context table
    resources/context.yaml
    """

    def __init__(self, table: Dict[str, Any]):
        """
        constructor

        Args:
            table: the context table: namespaces, provisional terms and classes
        """
        self.table = table
        self.classes = table["classes"]

    @classmethod
    def default_table_path(cls) -> str:
        """
        get the path of the context table

        Returns:
            str: the path of resources/context.yaml
        """
        path = os.path.join(os.path.dirname(__file__), "resources", "context.yaml")
        return path

    @classmethod
    def of_yaml(cls, path: Optional[str] = None) -> "JsonLd":
        """
        create the JSON-LD support from the given context table

        Args:
            path: the path of the yaml file, the default table if None

        Returns:
            JsonLd: the JSON-LD support
        """
        if path is None:
            path = cls.default_table_path()
        with open(path, "r", encoding="utf-8") as table_file:
            table = yaml.safe_load(table_file)
        jsonld = cls(table)
        return jsonld

    def term_definition(self, term: Dict[str, Any]) -> Dict[str, Any]:
        """
        get the JSON-LD term definition for the given row of the context table

        Args:
            term: the row: id or reverse, optionally type and container

        Returns:
            Dict[str, Any]: the term definition
        """
        definition = {}
        if "reverse" in term:
            definition["@reverse"] = term["reverse"]
        else:
            definition["@id"] = term["id"]
        if "type" in term:
            definition["@type"] = term["type"]
        if "container" in term:
            definition["@container"] = term["container"]
        return definition

    def context(self) -> Dict[str, Any]:
        """
        get the JSON-LD context: the namespaces and per class a type scoped
        context with the terms of its fields

        Returns:
            Dict[str, Any]: the value of @context
        """
        context: Dict[str, Any] = {"@version": 1.1}
        context.update(self.table["namespaces"])
        for class_name, class_row in self.classes.items():
            scoped = {}
            for field_name, term in class_row["terms"].items():
                scoped[field_name] = self.term_definition(term)
            context[class_name] = {"@id": class_row["type"], "@context": scoped}
        return context

    def node(self, instance: Any) -> Dict[str, Any]:
        """
        get the JSON-LD node of the given object of the research model

        Fields without value are left out.

        Args:
            instance: the object e.g. a Proceedings

        Returns:
            Dict[str, Any]: the node with @type and, if the class has an id field, @id
        """
        class_name = type(instance).__name__
        class_row = self.classes[class_name]
        node: Dict[str, Any] = {"@type": class_name}
        id_field = class_row.get("id_field")
        if id_field and getattr(instance, id_field):
            node["@id"] = getattr(instance, id_field)
        for field in dataclasses.fields(instance):
            value = getattr(instance, field.name)
            if value is None or value == []:
                continue
            if dataclasses.is_dataclass(value):
                value = self.node(value)
            elif isinstance(value, list):
                value = [self.node(item) for item in value]
            node[field.name] = value
        return node

    def document(self, instance: Any) -> Dict[str, Any]:
        """
        get the JSON-LD document of the given object of the research model

        Args:
            instance: the object e.g. a Proceedings or a Paper

        Returns:
            Dict[str, Any]: the document with @context
        """
        document = {"@context": self.context()}
        document.update(self.node(instance))
        return document

    def as_text(self, instance: Any) -> str:
        """
        get the pretty printed JSON-LD document of the given object

        Args:
            instance: the object e.g. a Proceedings or a Paper

        Returns:
            str: the JSON-LD with an indent of two
        """
        text = json.dumps(self.document(instance), indent=2, ensure_ascii=False)
        return text

    def plain(self, value: Any) -> Any:
        """
        remove the JSON-LD keywords from the given value

        Args:
            value: a node, a list of nodes or a plain value

        Returns:
            Any: the value without the keys that start with @
        """
        plain = value
        if isinstance(value, dict):
            plain = {key: self.plain(item) for key, item in value.items() if not key.startswith("@")}
        elif isinstance(value, list):
            plain = [self.plain(item) for item in value]
        return plain

    def proceedings(self, document: Dict[str, Any]) -> Proceedings:
        """
        get the proceedings of the given JSON-LD document

        Args:
            document: a document as created by the document method

        Returns:
            Proceedings: the proceedings
        """
        proceedings = Proceedings.from_dict(self.plain(document))
        return proceedings

    def paper(self, document: Dict[str, Any]) -> Paper:
        """
        get the paper of the given JSON-LD document

        Args:
            document: a document as created by the document method for a paper

        Returns:
            Paper: the paper
        """
        paper = Paper.from_dict(self.plain(document))
        return paper

__init__(table)

constructor

Parameters:

Name Type Description Default
table Dict[str, Any]

the context table: namespaces, provisional terms and classes

required
Source code in ceursemantify/jsonld.py
23
24
25
26
27
28
29
30
31
def __init__(self, table: Dict[str, Any]):
    """
    constructor

    Args:
        table: the context table: namespaces, provisional terms and classes
    """
    self.table = table
    self.classes = table["classes"]

as_text(instance)

get the pretty printed JSON-LD document of the given object

Parameters:

Name Type Description Default
instance Any

the object e.g. a Proceedings or a Paper

required

Returns:

Name Type Description
str str

the JSON-LD with an indent of two

Source code in ceursemantify/jsonld.py
143
144
145
146
147
148
149
150
151
152
153
154
def as_text(self, instance: Any) -> str:
    """
    get the pretty printed JSON-LD document of the given object

    Args:
        instance: the object e.g. a Proceedings or a Paper

    Returns:
        str: the JSON-LD with an indent of two
    """
    text = json.dumps(self.document(instance), indent=2, ensure_ascii=False)
    return text

context()

get the JSON-LD context: the namespaces and per class a type scoped context with the terms of its fields

Returns:

Type Description
Dict[str, Any]

Dict[str, Any]: the value of @context

Source code in ceursemantify/jsonld.py
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def context(self) -> Dict[str, Any]:
    """
    get the JSON-LD context: the namespaces and per class a type scoped
    context with the terms of its fields

    Returns:
        Dict[str, Any]: the value of @context
    """
    context: Dict[str, Any] = {"@version": 1.1}
    context.update(self.table["namespaces"])
    for class_name, class_row in self.classes.items():
        scoped = {}
        for field_name, term in class_row["terms"].items():
            scoped[field_name] = self.term_definition(term)
        context[class_name] = {"@id": class_row["type"], "@context": scoped}
    return context

default_table_path() classmethod

get the path of the context table

Returns:

Name Type Description
str str

the path of resources/context.yaml

Source code in ceursemantify/jsonld.py
33
34
35
36
37
38
39
40
41
42
@classmethod
def default_table_path(cls) -> str:
    """
    get the path of the context table

    Returns:
        str: the path of resources/context.yaml
    """
    path = os.path.join(os.path.dirname(__file__), "resources", "context.yaml")
    return path

document(instance)

get the JSON-LD document of the given object of the research model

Parameters:

Name Type Description Default
instance Any

the object e.g. a Proceedings or a Paper

required

Returns:

Type Description
Dict[str, Any]

Dict[str, Any]: the document with @context

Source code in ceursemantify/jsonld.py
129
130
131
132
133
134
135
136
137
138
139
140
141
def document(self, instance: Any) -> Dict[str, Any]:
    """
    get the JSON-LD document of the given object of the research model

    Args:
        instance: the object e.g. a Proceedings or a Paper

    Returns:
        Dict[str, Any]: the document with @context
    """
    document = {"@context": self.context()}
    document.update(self.node(instance))
    return document

node(instance)

get the JSON-LD node of the given object of the research model

Fields without value are left out.

Parameters:

Name Type Description Default
instance Any

the object e.g. a Proceedings

required

Returns:

Type Description
Dict[str, Any]

Dict[str, Any]: the node with @type and, if the class has an id field, @id

Source code in ceursemantify/jsonld.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def node(self, instance: Any) -> Dict[str, Any]:
    """
    get the JSON-LD node of the given object of the research model

    Fields without value are left out.

    Args:
        instance: the object e.g. a Proceedings

    Returns:
        Dict[str, Any]: the node with @type and, if the class has an id field, @id
    """
    class_name = type(instance).__name__
    class_row = self.classes[class_name]
    node: Dict[str, Any] = {"@type": class_name}
    id_field = class_row.get("id_field")
    if id_field and getattr(instance, id_field):
        node["@id"] = getattr(instance, id_field)
    for field in dataclasses.fields(instance):
        value = getattr(instance, field.name)
        if value is None or value == []:
            continue
        if dataclasses.is_dataclass(value):
            value = self.node(value)
        elif isinstance(value, list):
            value = [self.node(item) for item in value]
        node[field.name] = value
    return node

of_yaml(path=None) classmethod

create the JSON-LD support from the given context table

Parameters:

Name Type Description Default
path Optional[str]

the path of the yaml file, the default table if None

None

Returns:

Name Type Description
JsonLd JsonLd

the JSON-LD support

Source code in ceursemantify/jsonld.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@classmethod
def of_yaml(cls, path: Optional[str] = None) -> "JsonLd":
    """
    create the JSON-LD support from the given context table

    Args:
        path: the path of the yaml file, the default table if None

    Returns:
        JsonLd: the JSON-LD support
    """
    if path is None:
        path = cls.default_table_path()
    with open(path, "r", encoding="utf-8") as table_file:
        table = yaml.safe_load(table_file)
    jsonld = cls(table)
    return jsonld

paper(document)

get the paper of the given JSON-LD document

Parameters:

Name Type Description Default
document Dict[str, Any]

a document as created by the document method for a paper

required

Returns:

Name Type Description
Paper Paper

the paper

Source code in ceursemantify/jsonld.py
186
187
188
189
190
191
192
193
194
195
196
197
def paper(self, document: Dict[str, Any]) -> Paper:
    """
    get the paper of the given JSON-LD document

    Args:
        document: a document as created by the document method for a paper

    Returns:
        Paper: the paper
    """
    paper = Paper.from_dict(self.plain(document))
    return paper

plain(value)

remove the JSON-LD keywords from the given value

Parameters:

Name Type Description Default
value Any

a node, a list of nodes or a plain value

required

Returns:

Name Type Description
Any Any

the value without the keys that start with @

Source code in ceursemantify/jsonld.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def plain(self, value: Any) -> Any:
    """
    remove the JSON-LD keywords from the given value

    Args:
        value: a node, a list of nodes or a plain value

    Returns:
        Any: the value without the keys that start with @
    """
    plain = value
    if isinstance(value, dict):
        plain = {key: self.plain(item) for key, item in value.items() if not key.startswith("@")}
    elif isinstance(value, list):
        plain = [self.plain(item) for item in value]
    return plain

proceedings(document)

get the proceedings of the given JSON-LD document

Parameters:

Name Type Description Default
document Dict[str, Any]

a document as created by the document method

required

Returns:

Name Type Description
Proceedings Proceedings

the proceedings

Source code in ceursemantify/jsonld.py
173
174
175
176
177
178
179
180
181
182
183
184
def proceedings(self, document: Dict[str, Any]) -> Proceedings:
    """
    get the proceedings of the given JSON-LD document

    Args:
        document: a document as created by the document method

    Returns:
        Proceedings: the proceedings
    """
    proceedings = Proceedings.from_dict(self.plain(document))
    return proceedings

term_definition(term)

get the JSON-LD term definition for the given row of the context table

Parameters:

Name Type Description Default
term Dict[str, Any]

the row: id or reverse, optionally type and container

required

Returns:

Type Description
Dict[str, Any]

Dict[str, Any]: the term definition

Source code in ceursemantify/jsonld.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def term_definition(self, term: Dict[str, Any]) -> Dict[str, Any]:
    """
    get the JSON-LD term definition for the given row of the context table

    Args:
        term: the row: id or reverse, optionally type and container

    Returns:
        Dict[str, Any]: the term definition
    """
    definition = {}
    if "reverse" in term:
        definition["@reverse"] = term["reverse"]
    else:
        definition["@id"] = term["id"]
    if "type" in term:
        definition["@type"] = term["type"]
    if "container" in term:
        definition["@container"] = term["container"]
    return definition

loctime

Created on 2026-10-13

@author: wf

LocTime dataclass

location and time of an event as given in the CEURLOCTIME text of a volume page e.g. Verona, Italy, June 8-12, 2026

Source code in ceursemantify/loctime.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@dataclass
class LocTime:
    """
    location and time of an event as given in the CEURLOCTIME text of a
    volume page e.g. Verona, Italy, June 8-12, 2026
    """

    text: str
    location: Optional[str] = None
    city: Optional[str] = None
    country: Optional[str] = None
    virtual: bool = False
    startDate: Optional[str] = None
    endDate: Optional[str] = None
    # why the date could not be parsed, None if it was
    message: Optional[str] = None

    MONTH = r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?"
    DAY = r"\d{1,2}(?:st|nd|rd|th)?"
    SEP = r"\s*(?:-|–|—|to|until)\s*"
    YEAR = r"\d{4}"
    # the date forms, the most specific first; m: month, d: day, y: year
    DATE_FORMS = [
        # December 30, 2025 - January 2, 2026
        rf"(?P<m1>{MONTH})\s+(?P<d1>{DAY}),?\s+(?P<y1>{YEAR}){SEP}(?P<m2>{MONTH})\s+(?P<d2>{DAY}),?\s+(?P<y2>{YEAR})",
        # September 29 - October 3, 2025
        rf"(?P<m1>{MONTH})\s+(?P<d1>{DAY}){SEP}(?P<m2>{MONTH})\s+(?P<d2>{DAY}),?\s+(?P<y1>{YEAR})",
        # June 8-12, 2026
        rf"(?P<m1>{MONTH})\s+(?P<d1>{DAY}){SEP}(?P<d2>{DAY}),?\s+(?P<y1>{YEAR})",
        # March 1st, 2021
        rf"(?P<m1>{MONTH})\s+(?P<d1>{DAY}),?\s+(?P<y1>{YEAR})",
        # 30 June - 2 July 2026
        rf"(?P<d1>{DAY})\s+(?P<m1>{MONTH}){SEP}(?P<d2>{DAY})\s+(?P<m2>{MONTH}),?\s+(?P<y1>{YEAR})",
        # 1-3 December 2025
        rf"(?P<d1>{DAY}){SEP}(?P<d2>{DAY})\s+(?P<m1>{MONTH}),?\s+(?P<y1>{YEAR})",
        # 3 December 2025
        rf"(?P<d1>{DAY})\s+(?P<m1>{MONTH}),?\s+(?P<y1>{YEAR})",
    ]
    date_patterns = [re.compile(date_form) for date_form in DATE_FORMS]
    virtual_pattern = re.compile(r"\b(online|virtual)\b", re.IGNORECASE)
    months: ClassVar[Dict[str, int]] = {
        "jan": 1,
        "feb": 2,
        "mar": 3,
        "apr": 4,
        "may": 5,
        "jun": 6,
        "jul": 7,
        "aug": 8,
        "sep": 9,
        "oct": 10,
        "nov": 11,
        "dec": 12,
    }

    @classmethod
    def iso_date(cls, year: str, month: str, day: str) -> str:
        """
        get the ISO date of the given parts

        Args:
            year: the four digit year
            month: the month name, full or abbreviated
            day: the day, with an optional ordinal suffix

        Returns:
            str: the date as YYYY-MM-DD

        Raises:
            ValueError: if the parts are not a valid date
        """
        month_number = cls.months[month[:3].lower()]
        day_number = int(re.sub(r"\D", "", day))
        iso_date = date(int(year), month_number, day_number).isoformat()
        return iso_date

    @classmethod
    def parse(cls, text: str) -> "LocTime":
        """
        parse the given location and time text

        The date is the last part of the text; what is before it is the
        location, its first comma separated part the city and its last part
        the country. An online or virtual location has no city and no country.

        Args:
            text: the text e.g. Verona, Italy, June 8-12, 2026

        Returns:
            LocTime: the location and time, with a message if the date could not be parsed
        """
        text = " ".join(text.split())
        loctime = cls(text=text, location=text or None)
        match = None
        for pattern in cls.date_patterns:
            match = pattern.search(text)
            if match:
                break
        if match:
            parts = match.groupdict()
            try:
                loctime.startDate = cls.iso_date(parts["y1"], parts["m1"], parts["d1"])
                end_year = parts.get("y2") or parts["y1"]
                end_month = parts.get("m2") or parts["m1"]
                end_day = parts.get("d2") or parts["d1"]
                loctime.endDate = cls.iso_date(end_year, end_month, end_day)
            except ValueError as error:
                loctime.message = f"invalid date in {text}: {error}"
            loctime.location = text[: match.start()].rstrip(" ,.;") or None
        else:
            loctime.message = f"no date found in {text}" if text else "empty location and time"
        if loctime.location:
            if cls.virtual_pattern.search(loctime.location):
                loctime.virtual = True
            else:
                parts = [part.strip() for part in loctime.location.split(",") if part.strip()]
                loctime.city = parts[0]
                if len(parts) > 1:
                    loctime.country = parts[-1]
        return loctime

iso_date(year, month, day) classmethod

get the ISO date of the given parts

Parameters:

Name Type Description Default
year str

the four digit year

required
month str

the month name, full or abbreviated

required
day str

the day, with an optional ordinal suffix

required

Returns:

Name Type Description
str str

the date as YYYY-MM-DD

Raises:

Type Description
ValueError

if the parts are not a valid date

Source code in ceursemantify/loctime.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@classmethod
def iso_date(cls, year: str, month: str, day: str) -> str:
    """
    get the ISO date of the given parts

    Args:
        year: the four digit year
        month: the month name, full or abbreviated
        day: the day, with an optional ordinal suffix

    Returns:
        str: the date as YYYY-MM-DD

    Raises:
        ValueError: if the parts are not a valid date
    """
    month_number = cls.months[month[:3].lower()]
    day_number = int(re.sub(r"\D", "", day))
    iso_date = date(int(year), month_number, day_number).isoformat()
    return iso_date

parse(text) classmethod

parse the given location and time text

The date is the last part of the text; what is before it is the location, its first comma separated part the city and its last part the country. An online or virtual location has no city and no country.

Parameters:

Name Type Description Default
text str

the text e.g. Verona, Italy, June 8-12, 2026

required

Returns:

Name Type Description
LocTime LocTime

the location and time, with a message if the date could not be parsed

Source code in ceursemantify/loctime.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@classmethod
def parse(cls, text: str) -> "LocTime":
    """
    parse the given location and time text

    The date is the last part of the text; what is before it is the
    location, its first comma separated part the city and its last part
    the country. An online or virtual location has no city and no country.

    Args:
        text: the text e.g. Verona, Italy, June 8-12, 2026

    Returns:
        LocTime: the location and time, with a message if the date could not be parsed
    """
    text = " ".join(text.split())
    loctime = cls(text=text, location=text or None)
    match = None
    for pattern in cls.date_patterns:
        match = pattern.search(text)
        if match:
            break
    if match:
        parts = match.groupdict()
        try:
            loctime.startDate = cls.iso_date(parts["y1"], parts["m1"], parts["d1"])
            end_year = parts.get("y2") or parts["y1"]
            end_month = parts.get("m2") or parts["m1"]
            end_day = parts.get("d2") or parts["d1"]
            loctime.endDate = cls.iso_date(end_year, end_month, end_day)
        except ValueError as error:
            loctime.message = f"invalid date in {text}: {error}"
        loctime.location = text[: match.start()].rstrip(" ,.;") or None
    else:
        loctime.message = f"no date found in {text}" if text else "empty location and time"
    if loctime.location:
        if cls.virtual_pattern.search(loctime.location):
            loctime.virtual = True
        else:
            parts = [part.strip() for part in loctime.location.split(",") if part.strip()]
            loctime.city = parts[0]
            if len(parts) > 1:
                loctime.country = parts[-1]
    return loctime

mapper

Created on 2026-10-01

@author: wf

Mapper

map source records to the research model as declared in a mapping table

Source code in ceursemantify/mapper.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
class Mapper:
    """
    map source records to the research model as declared in a mapping table
    """

    def __init__(self, mapping: Dict[str, Any]):
        """
        constructor

        Args:
            mapping: the mapping table: per target class the fields and the ignored source keys
        """
        self.mapping = mapping

    @classmethod
    def default_mapping_path(cls) -> str:
        """
        get the path of the mapping table for the JSON-LD of ceur-spt

        Returns:
            str: the path of resources/ceurspt_mapping.yaml
        """
        path = os.path.join(os.path.dirname(__file__), "resources", "ceurspt_mapping.yaml")
        return path

    @classmethod
    def of_yaml(cls, path: Optional[str] = None) -> "Mapper":
        """
        create a mapper from the given mapping table

        Args:
            path: the path of the yaml file, the ceur-spt mapping if None

        Returns:
            Mapper: the mapper
        """
        if path is None:
            path = cls.default_mapping_path()
        with open(path, "r", encoding="utf-8") as mapping_file:
            mapping = yaml.safe_load(mapping_file)
        mapper = cls(mapping)
        return mapper

    def target_class(self, class_name: str) -> Type:
        """
        get the class of the research model with the given name

        Args:
            class_name: the name of the class e.g. Proceedings

        Returns:
            Type: the class
        """
        target_class = getattr(model, class_name)
        return target_class

    def convert(self, value: Any, rule: Dict[str, Any], result: MappingResult) -> Any:
        """
        convert a source value according to the given rule

        Args:
            value: the source value
            rule: the rule of the mapping table for the target field
            result: the mapping result to add issues to

        Returns:
            Any: the converted value, None if the value could not be converted
        """
        converted = value
        nested_class = rule.get("class")
        conversion = rule.get("as")
        if nested_class:
            converted = self.map_nested(value, nested_class, rule.get("into"), result)
        elif conversion:
            converted = getattr(Convert, conversion)(value)
        return converted

    def map_nested(self, value: Any, class_name: str, into: Optional[str], result: MappingResult) -> Any:
        """
        map a nested source value to an object or a list of objects

        Args:
            value: a source record, a list of source records or a plain value
            class_name: the name of the target class
            into: the field of the target class that takes a plain value
            result: the mapping result to add issues to

        Returns:
            Any: the object or list of objects
        """
        if isinstance(value, list):
            nested = [self.map_nested(item, class_name, into, result) for item in value]
        elif isinstance(value, dict):
            nested_result = self.map_record(value, class_name)
            result.issues.extend(nested_result.issues)
            nested = nested_result.target
        elif into:
            nested = self.target_class(class_name)(**{into: Convert.text(value)})
        else:
            raise ValueError(f"plain value {value} for {class_name} without into field")
        return nested

    def map_record(self, record: Dict[str, Any], class_name: str) -> MappingResult:
        """
        map the given source record to an object of the given class

        Source keys that are neither mapped nor ignored and values that can
        not be converted are reported as issues.

        Args:
            record: the source record
            class_name: the name of the target class e.g. Proceedings

        Returns:
            MappingResult: the object and the issues
        """
        result = MappingResult()
        class_mapping = self.mapping[class_name]
        rules = class_mapping.get("fields") or {}
        ignored = class_mapping.get("ignore") or {}
        values = {}
        mapped_keys = set()
        for target_field, rule in rules.items():
            source_key = rule["from"]
            mapped_keys.add(source_key)
            value = record.get(source_key)
            if value is None or value == "":
                continue
            try:
                values[target_field] = self.convert(value, rule, result)
            except ValueError as error:
                result.issues.append(MappingIssue(class_name, source_key, str(error)))
        for source_key in record:
            if source_key not in mapped_keys and source_key not in ignored:
                result.issues.append(MappingIssue(class_name, source_key, "not in the mapping table"))
        result.target = self.target_class(class_name)(**values)
        return result

__init__(mapping)

constructor

Parameters:

Name Type Description Default
mapping Dict[str, Any]

the mapping table: per target class the fields and the ignored source keys

required
Source code in ceursemantify/mapper.py
47
48
49
50
51
52
53
54
def __init__(self, mapping: Dict[str, Any]):
    """
    constructor

    Args:
        mapping: the mapping table: per target class the fields and the ignored source keys
    """
    self.mapping = mapping

convert(value, rule, result)

convert a source value according to the given rule

Parameters:

Name Type Description Default
value Any

the source value

required
rule Dict[str, Any]

the rule of the mapping table for the target field

required
result MappingResult

the mapping result to add issues to

required

Returns:

Name Type Description
Any Any

the converted value, None if the value could not be converted

Source code in ceursemantify/mapper.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def convert(self, value: Any, rule: Dict[str, Any], result: MappingResult) -> Any:
    """
    convert a source value according to the given rule

    Args:
        value: the source value
        rule: the rule of the mapping table for the target field
        result: the mapping result to add issues to

    Returns:
        Any: the converted value, None if the value could not be converted
    """
    converted = value
    nested_class = rule.get("class")
    conversion = rule.get("as")
    if nested_class:
        converted = self.map_nested(value, nested_class, rule.get("into"), result)
    elif conversion:
        converted = getattr(Convert, conversion)(value)
    return converted

default_mapping_path() classmethod

get the path of the mapping table for the JSON-LD of ceur-spt

Returns:

Name Type Description
str str

the path of resources/ceurspt_mapping.yaml

Source code in ceursemantify/mapper.py
56
57
58
59
60
61
62
63
64
65
@classmethod
def default_mapping_path(cls) -> str:
    """
    get the path of the mapping table for the JSON-LD of ceur-spt

    Returns:
        str: the path of resources/ceurspt_mapping.yaml
    """
    path = os.path.join(os.path.dirname(__file__), "resources", "ceurspt_mapping.yaml")
    return path

map_nested(value, class_name, into, result)

map a nested source value to an object or a list of objects

Parameters:

Name Type Description Default
value Any

a source record, a list of source records or a plain value

required
class_name str

the name of the target class

required
into Optional[str]

the field of the target class that takes a plain value

required
result MappingResult

the mapping result to add issues to

required

Returns:

Name Type Description
Any Any

the object or list of objects

Source code in ceursemantify/mapper.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def map_nested(self, value: Any, class_name: str, into: Optional[str], result: MappingResult) -> Any:
    """
    map a nested source value to an object or a list of objects

    Args:
        value: a source record, a list of source records or a plain value
        class_name: the name of the target class
        into: the field of the target class that takes a plain value
        result: the mapping result to add issues to

    Returns:
        Any: the object or list of objects
    """
    if isinstance(value, list):
        nested = [self.map_nested(item, class_name, into, result) for item in value]
    elif isinstance(value, dict):
        nested_result = self.map_record(value, class_name)
        result.issues.extend(nested_result.issues)
        nested = nested_result.target
    elif into:
        nested = self.target_class(class_name)(**{into: Convert.text(value)})
    else:
        raise ValueError(f"plain value {value} for {class_name} without into field")
    return nested

map_record(record, class_name)

map the given source record to an object of the given class

Source keys that are neither mapped nor ignored and values that can not be converted are reported as issues.

Parameters:

Name Type Description Default
record Dict[str, Any]

the source record

required
class_name str

the name of the target class e.g. Proceedings

required

Returns:

Name Type Description
MappingResult MappingResult

the object and the issues

Source code in ceursemantify/mapper.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def map_record(self, record: Dict[str, Any], class_name: str) -> MappingResult:
    """
    map the given source record to an object of the given class

    Source keys that are neither mapped nor ignored and values that can
    not be converted are reported as issues.

    Args:
        record: the source record
        class_name: the name of the target class e.g. Proceedings

    Returns:
        MappingResult: the object and the issues
    """
    result = MappingResult()
    class_mapping = self.mapping[class_name]
    rules = class_mapping.get("fields") or {}
    ignored = class_mapping.get("ignore") or {}
    values = {}
    mapped_keys = set()
    for target_field, rule in rules.items():
        source_key = rule["from"]
        mapped_keys.add(source_key)
        value = record.get(source_key)
        if value is None or value == "":
            continue
        try:
            values[target_field] = self.convert(value, rule, result)
        except ValueError as error:
            result.issues.append(MappingIssue(class_name, source_key, str(error)))
    for source_key in record:
        if source_key not in mapped_keys and source_key not in ignored:
            result.issues.append(MappingIssue(class_name, source_key, "not in the mapping table"))
    result.target = self.target_class(class_name)(**values)
    return result

of_yaml(path=None) classmethod

create a mapper from the given mapping table

Parameters:

Name Type Description Default
path Optional[str]

the path of the yaml file, the ceur-spt mapping if None

None

Returns:

Name Type Description
Mapper Mapper

the mapper

Source code in ceursemantify/mapper.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@classmethod
def of_yaml(cls, path: Optional[str] = None) -> "Mapper":
    """
    create a mapper from the given mapping table

    Args:
        path: the path of the yaml file, the ceur-spt mapping if None

    Returns:
        Mapper: the mapper
    """
    if path is None:
        path = cls.default_mapping_path()
    with open(path, "r", encoding="utf-8") as mapping_file:
        mapping = yaml.safe_load(mapping_file)
    mapper = cls(mapping)
    return mapper

target_class(class_name)

get the class of the research model with the given name

Parameters:

Name Type Description Default
class_name str

the name of the class e.g. Proceedings

required

Returns:

Name Type Description
Type Type

the class

Source code in ceursemantify/mapper.py
85
86
87
88
89
90
91
92
93
94
95
96
def target_class(self, class_name: str) -> Type:
    """
    get the class of the research model with the given name

    Args:
        class_name: the name of the class e.g. Proceedings

    Returns:
        Type: the class
    """
    target_class = getattr(model, class_name)
    return target_class

MappingIssue dataclass

a source value that could not be mapped

Source code in ceursemantify/mapper.py
17
18
19
20
21
22
23
24
25
26
27
28
29
@dataclass
class MappingIssue:
    """
    a source value that could not be mapped
    """

    target_class: str
    source_key: str
    message: str

    def __str__(self) -> str:
        text = f"{self.target_class}: {self.source_key}: {self.message}"
        return text

MappingResult dataclass

the result of mapping one source record

Source code in ceursemantify/mapper.py
32
33
34
35
36
37
38
39
@dataclass
class MappingResult:
    """
    the result of mapping one source record
    """

    target: Any = None
    issues: List[MappingIssue] = field(default_factory=list)

model

Created on 2026-10-01

Research model of CEUR-WS: the core entities and CrSchema of https://cr.bitplan.com/index.php/CoreEntities and https://cr.bitplan.com/index.php/CrSchema

Field names follow CrSchema. Where CrSchema has no property the term of the CEUR-WS proceedings ontology in the ontology directory is used. Fields that have a term in neither are listed as provisional in resources/context.yaml.

@author: wf

City

large permanent human settlement

Source code in ceursemantify/model.py
22
23
24
25
26
27
28
29
@lod_storable
class City:
    """
    large permanent human settlement
    """

    name: Optional[str] = None
    wikidataid: Optional[str] = None

Country

distinct region in geography

Source code in ceursemantify/model.py
32
33
34
35
36
37
38
39
@lod_storable
class Country:
    """
    distinct region in geography
    """

    name: Optional[str] = None
    wikidataid: Optional[str] = None

Event

a meeting of researchers at a specific time and place

Source code in ceursemantify/model.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
@lod_storable
class Event:
    """
    a meeting of researchers at a specific time and place
    """

    acronym: Optional[str] = None
    ordinal: Optional[int] = None
    title: Optional[str] = None
    homepage: Optional[str] = None
    wikidataid: Optional[str] = None
    startDate: Optional[str] = None
    endDate: Optional[str] = None
    location: Optional[str] = None
    virtual: Optional[bool] = None
    city: Optional[City] = None
    country: Optional[Country] = None
    colocated_with: Optional["Event"] = None

    def year(self) -> Optional[int]:
        """
        get the year of the event

        Returns:
            Optional[int]: the year of the start date, None if there is no start date
        """
        year = None
        if self.startDate:
            year = int(self.startDate[:4])
        return year

year()

get the year of the event

Returns:

Type Description
Optional[int]

Optional[int]: the year of the start date, None if there is no start date

Source code in ceursemantify/model.py
114
115
116
117
118
119
120
121
122
123
124
def year(self) -> Optional[int]:
    """
    get the year of the event

    Returns:
        Optional[int]: the year of the start date, None if there is no start date
    """
    year = None
    if self.startDate:
        year = int(self.startDate[:4])
    return year

Paper

a paper is e.g. a scholarly article

Source code in ceursemantify/model.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
@lod_storable
class Paper:
    """
    a paper is e.g. a scholarly article
    """

    id: Optional[str] = None
    wikidataid: Optional[str] = None
    doi: Optional[str] = None
    title: Optional[str] = None
    # the landing page of the paper, the paper as a node with its metadata and links, see issue 32
    paperUrl: Optional[str] = None
    # the file
    pdfUrl: Optional[str] = None
    pageStart: Optional[str] = None
    pageEnd: Optional[str] = None
    dblpPublicationId: Optional[str] = None
    session: Optional[str] = None
    publishedIn: Optional[str] = None
    authors: List[Scholar] = field(default_factory=list)

    def publish_in(self, url: str) -> None:
        """
        set the volume the paper is published in and the landing page of the
        paper under it, the directory named by the paper id, see issue 32

        Args:
            url: the url of the volume e.g. https://ceur-ws.org/Vol-4298/
        """
        self.publishedIn = url
        if self.id:
            self.paperUrl = urljoin(url, f"{self.id}/")

publish_in(url)

set the volume the paper is published in and the landing page of the paper under it, the directory named by the paper id, see issue 32

Parameters:

Name Type Description Default
url str

the url of the volume e.g. https://ceur-ws.org/Vol-4298/

required
Source code in ceursemantify/model.py
148
149
150
151
152
153
154
155
156
157
158
def publish_in(self, url: str) -> None:
    """
    set the volume the paper is published in and the landing page of the
    paper under it, the directory named by the paper id, see issue 32

    Args:
        url: the url of the volume e.g. https://ceur-ws.org/Vol-4298/
    """
    self.publishedIn = url
    if self.id:
        self.paperUrl = urljoin(url, f"{self.id}/")

Proceedings

a collection of papers mostly documenting the results of an academic event

Source code in ceursemantify/model.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
@lod_storable
class Proceedings:
    """
    a collection of papers mostly documenting the results of an academic event
    """

    wikidataid: Optional[str] = None
    doi: Optional[str] = None
    title: Optional[str] = None
    volumeNumber: Optional[int] = None
    urn: Optional[str] = None
    pubDate: Optional[str] = None
    pubYear: Optional[int] = None
    url: Optional[str] = None
    archive: Optional[str] = None
    license: Optional[str] = None
    submittedPapers: Optional[int] = None
    acceptedPapers: Optional[int] = None
    publisher: Optional[Publisher] = None
    event: Optional[Event] = None
    editors: List[Scholar] = field(default_factory=list)
    papers: List[Paper] = field(default_factory=list)

    def year(self) -> Optional[int]:
        """
        get the year the proceedings belong to: the year of the event

        Returns:
            Optional[int]: the year, None if there is no event start date
        """
        year = None
        if self.event:
            year = self.event.year()
        return year

year()

get the year the proceedings belong to: the year of the event

Returns:

Type Description
Optional[int]

Optional[int]: the year, None if there is no event start date

Source code in ceursemantify/model.py
184
185
186
187
188
189
190
191
192
193
194
def year(self) -> Optional[int]:
    """
    get the year the proceedings belong to: the year of the event

    Returns:
        Optional[int]: the year, None if there is no event start date
    """
    year = None
    if self.event:
        year = self.event.year()
    return year

Publisher

organization that publishes proceedings

Source code in ceursemantify/model.py
42
43
44
45
46
47
48
49
@lod_storable
class Publisher:
    """
    organization that publishes proceedings
    """

    name: Optional[str] = None
    wikidataid: Optional[str] = None

Scholar

person who engages in research

Source code in ceursemantify/model.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@lod_storable
class Scholar:
    """
    person who engages in research
    """

    name: Optional[str] = None
    firstName: Optional[str] = None
    orcid: Optional[str] = None
    dblpId: Optional[str] = None
    wikiDataId: Optional[str] = None
    gndId: Optional[str] = None

    @classmethod
    def of_full_name(cls, full_name: str) -> "Scholar":
        """
        get a scholar from a full name as written on a volume page

        The last word is taken as the family name, the words before it as
        the first name; a heuristic that fails e.g. for Spanish double surnames.

        Args:
            full_name: the name e.g. Mieke Jans

        Returns:
            Scholar: the scholar
        """
        parts = full_name.split()
        scholar = cls(name=parts[-1] if parts else None, firstName=" ".join(parts[:-1]) or None)
        return scholar

    def full_name(self) -> str:
        """
        get first name and last name of the scholar

        Returns:
            str: the name parts that are set, separated by a blank
        """
        parts = [part for part in [self.firstName, self.name] if part]
        full_name = " ".join(parts)
        return full_name

full_name()

get first name and last name of the scholar

Returns:

Name Type Description
str str

the name parts that are set, separated by a blank

Source code in ceursemantify/model.py
83
84
85
86
87
88
89
90
91
92
def full_name(self) -> str:
    """
    get first name and last name of the scholar

    Returns:
        str: the name parts that are set, separated by a blank
    """
    parts = [part for part in [self.firstName, self.name] if part]
    full_name = " ".join(parts)
    return full_name

of_full_name(full_name) classmethod

get a scholar from a full name as written on a volume page

The last word is taken as the family name, the words before it as the first name; a heuristic that fails e.g. for Spanish double surnames.

Parameters:

Name Type Description Default
full_name str

the name e.g. Mieke Jans

required

Returns:

Name Type Description
Scholar Scholar

the scholar

Source code in ceursemantify/model.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
@classmethod
def of_full_name(cls, full_name: str) -> "Scholar":
    """
    get a scholar from a full name as written on a volume page

    The last word is taken as the family name, the words before it as
    the first name; a heuristic that fails e.g. for Spanish double surnames.

    Args:
        full_name: the name e.g. Mieke Jans

    Returns:
        Scholar: the scholar
    """
    parts = full_name.split()
    scholar = cls(name=parts[-1] if parts else None, firstName=" ".join(parts[:-1]) or None)
    return scholar

pages

Created on 2026-10-01

HTML pages of the generated site: each view has an as_html method that escapes its values first and then fills a multiline f-string.

@author: wf

Fence

JSON-LD as semantify³ fence in an html comment

Source code in ceursemantify/pages.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class Fence:
    """
    JSON-LD as semantify³ fence in an html comment
    """

    marker = "🌐🕸"
    fence = "`" * 3

    @classmethod
    def as_html(cls, jsonld_text: str) -> str:
        """
        get the html comment with the json-ld fence

        An html comment ends at the first -->; the JSON string escape
        \\u003e for > keeps the JSON equal and the comment intact.

        Args:
            jsonld_text: the JSON-LD

        Returns:
            str: the html comment
        """
        safe_text = jsonld_text.replace("-->", "--\\u003e")
        comment = f"""<!--
{cls.fence}json-ld
{cls.marker}
{safe_text}
{cls.fence}
-->"""
        return comment

as_html(jsonld_text) classmethod

get the html comment with the json-ld fence

An html comment ends at the first -->; the JSON string escape \u003e for > keeps the JSON equal and the comment intact.

Parameters:

Name Type Description Default
jsonld_text str

the JSON-LD

required

Returns:

Name Type Description
str str

the html comment

Source code in ceursemantify/pages.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
    @classmethod
    def as_html(cls, jsonld_text: str) -> str:
        """
        get the html comment with the json-ld fence

        An html comment ends at the first -->; the JSON string escape
        \\u003e for > keeps the JSON equal and the comment intact.

        Args:
            jsonld_text: the JSON-LD

        Returns:
            str: the html comment
        """
        safe_text = jsonld_text.replace("-->", "--\\u003e")
        comment = f"""<!--
{cls.fence}json-ld
{cls.marker}
{safe_text}
{cls.fence}
-->"""
        return comment

Header

the page header: an html snippet outside the code with {{ variable }} placeholders that are filled by the parameter handling of pyLoDStorage

Source code in ceursemantify/pages.py
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
class Header:
    """
    the page header: an html snippet outside the code with {{ variable }}
    placeholders that are filled by the parameter handling of pyLoDStorage
    """

    def __init__(self, config: SiteConfig):
        """
        constructor

        Args:
            config: the site configuration with the path of the snippet,
                without a path the header is the title as plain heading
        """
        self.config = config
        self.snippet = """  <h1>{{ title }}</h1>"""
        if config.header:
            with open(config.header, "r", encoding="utf-8") as header_file:
                self.snippet = header_file.read().rstrip("\n")

    def value(self, raw_value: Any) -> str:
        """
        prepare the given value for a placeholder

        Args:
            raw_value: the value

        Returns:
            str: the escaped text; a backslash and an opening brace are given as
                character references since the parameter handling replaces by
                regular expression and a value must not introduce a placeholder
        """
        value = esc(raw_value).replace("\\", "&#92;").replace("{", "&#123;")
        return value

    def values(self, title: str) -> Dict[str, str]:
        """
        get the values of the variables a snippet may use

        Args:
            title: the title of the page

        Returns:
            Dict[str, str]: the escaped value by variable name
        """
        raw_values = {
            "title": title,
            "series_title": self.config.series_title,
            "series_issn": self.config.series_issn,
            "original_url": self.config.original_url,
            "base_url": self.config.base_url,
        }
        values = {name: self.value(raw_value) for name, raw_value in raw_values.items()}
        return values

    def as_html(self, title: str) -> str:
        """
        get the header for the page with the given title

        Args:
            title: the title of the page

        Returns:
            str: the snippet with all placeholders filled

        Raises:
            ValueError: if the snippet uses a variable that is not available
        """
        values = self.values(title)
        # the values are escaped for html, the audit for query parameters does not apply
        params = Params(self.snippet, with_audit=False)
        unknown = sorted(set(params.params) - set(values.keys()))
        if unknown:
            raise ValueError(f"header {self.config.header} uses unknown variables: {', '.join(unknown)}")
        params.set(values)
        header = params.apply_parameters()
        return header

__init__(config)

constructor

Parameters:

Name Type Description Default
config SiteConfig

the site configuration with the path of the snippet, without a path the header is the title as plain heading

required
Source code in ceursemantify/pages.py
769
770
771
772
773
774
775
776
777
778
779
780
781
def __init__(self, config: SiteConfig):
    """
    constructor

    Args:
        config: the site configuration with the path of the snippet,
            without a path the header is the title as plain heading
    """
    self.config = config
    self.snippet = """  <h1>{{ title }}</h1>"""
    if config.header:
        with open(config.header, "r", encoding="utf-8") as header_file:
            self.snippet = header_file.read().rstrip("\n")

as_html(title)

get the header for the page with the given title

Parameters:

Name Type Description Default
title str

the title of the page

required

Returns:

Name Type Description
str str

the snippet with all placeholders filled

Raises:

Type Description
ValueError

if the snippet uses a variable that is not available

Source code in ceursemantify/pages.py
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
def as_html(self, title: str) -> str:
    """
    get the header for the page with the given title

    Args:
        title: the title of the page

    Returns:
        str: the snippet with all placeholders filled

    Raises:
        ValueError: if the snippet uses a variable that is not available
    """
    values = self.values(title)
    # the values are escaped for html, the audit for query parameters does not apply
    params = Params(self.snippet, with_audit=False)
    unknown = sorted(set(params.params) - set(values.keys()))
    if unknown:
        raise ValueError(f"header {self.config.header} uses unknown variables: {', '.join(unknown)}")
    params.set(values)
    header = params.apply_parameters()
    return header

value(raw_value)

prepare the given value for a placeholder

Parameters:

Name Type Description Default
raw_value Any

the value

required

Returns:

Name Type Description
str str

the escaped text; a backslash and an opening brace are given as character references since the parameter handling replaces by regular expression and a value must not introduce a placeholder

Source code in ceursemantify/pages.py
783
784
785
786
787
788
789
790
791
792
793
794
795
796
def value(self, raw_value: Any) -> str:
    """
    prepare the given value for a placeholder

    Args:
        raw_value: the value

    Returns:
        str: the escaped text; a backslash and an opening brace are given as
            character references since the parameter handling replaces by
            regular expression and a value must not introduce a placeholder
    """
    value = esc(raw_value).replace("\\", "&#92;").replace("{", "&#123;")
    return value

values(title)

get the values of the variables a snippet may use

Parameters:

Name Type Description Default
title str

the title of the page

required

Returns:

Type Description
Dict[str, str]

Dict[str, str]: the escaped value by variable name

Source code in ceursemantify/pages.py
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
def values(self, title: str) -> Dict[str, str]:
    """
    get the values of the variables a snippet may use

    Args:
        title: the title of the page

    Returns:
        Dict[str, str]: the escaped value by variable name
    """
    raw_values = {
        "title": title,
        "series_title": self.config.series_title,
        "series_issn": self.config.series_issn,
        "original_url": self.config.original_url,
        "base_url": self.config.base_url,
    }
    values = {name: self.value(raw_value) for name, raw_value in raw_values.items()}
    return values

PageState dataclass

the state a page announces in its header comment

Source code in ceursemantify/pages.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
@dataclass
class PageState:
    """
    the state a page announces in its header comment
    """

    generated_on: str
    version: str = ceursemantify.__version__

    @classmethod
    def today(cls) -> "PageState":
        """
        get the state for a page generated today

        Returns:
            PageState: the state
        """
        state = cls(generated_on=date.today().isoformat())
        return state

    def as_html(self, source: str, state: str = "generated") -> str:
        """
        get the header comment

        Args:
            source: what the page was generated from
            state: generated for a page written by the generator, embedded for
                the page of the proceedings chair with the JSON-LD embedded

        Returns:
            str: the html comment
        """
        comment = f"<!-- CEURSTATE={state}: on {self.generated_on} by pyCEURsemantify {self.version} from {source} -->"
        return comment

as_html(source, state='generated')

get the header comment

Parameters:

Name Type Description Default
source str

what the page was generated from

required
state str

generated for a page written by the generator, embedded for the page of the proceedings chair with the JSON-LD embedded

'generated'

Returns:

Name Type Description
str str

the html comment

Source code in ceursemantify/pages.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def as_html(self, source: str, state: str = "generated") -> str:
    """
    get the header comment

    Args:
        source: what the page was generated from
        state: generated for a page written by the generator, embedded for
            the page of the proceedings chair with the JSON-LD embedded

    Returns:
        str: the html comment
    """
    comment = f"<!-- CEURSTATE={state}: on {self.generated_on} by pyCEURsemantify {self.version} from {source} -->"
    return comment

today() classmethod

get the state for a page generated today

Returns:

Name Type Description
PageState PageState

the state

Source code in ceursemantify/pages.py
 97
 98
 99
100
101
102
103
104
105
106
@classmethod
def today(cls) -> "PageState":
    """
    get the state for a page generated today

    Returns:
        PageState: the state
    """
    state = cls(generated_on=date.today().isoformat())
    return state

PaperPage

the landing page of one paper

Source code in ceursemantify/pages.py
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
class PaperPage:
    """
    the landing page of one paper
    """

    def __init__(self, paper: Paper, proceedings: Proceedings, config: SiteConfig, jsonld: JsonLd, state: PageState):
        """
        constructor

        Args:
            paper: the paper
            proceedings: the proceedings the paper is published in
            config: the site configuration
            jsonld: the JSON-LD support of the research model
            state: the state to announce
        """
        self.paper = paper
        self.proceedings = proceedings
        self.config = config
        self.jsonld = jsonld
        self.state = state

    def jsonld_text(self) -> str:
        """
        get the pretty printed JSON-LD of the paper

        Returns:
            str: the JSON-LD
        """
        text = self.jsonld.as_text(self.paper)
        return text

    def navigation_as_html(self) -> str:
        """
        get the links to the previous and the next paper of the volume

        Returns:
            str: the html paragraph
        """
        paper_ids = [paper.id for paper in self.proceedings.papers if paper.id]
        position = paper_ids.index(self.paper.id)
        parts = []
        if position > 0:
            previous_id = esc(paper_ids[position - 1])
            parts.append(f"""<a href="../{previous_id}/" rel="prev">&laquo; {previous_id}</a>""")
        parts.append(esc(self.paper.id))
        if position < len(paper_ids) - 1:
            next_id = esc(paper_ids[position + 1])
            parts.append(f"""<a href="../{next_id}/" rel="next">{next_id} &raquo;</a>""")
        links = " | ".join(parts)
        navigation = f"""  <p>Paper: {links}</p>"""
        return navigation

    def author_as_html(self, author: Scholar) -> str:
        """
        get one author with the links to the identifiers

        Args:
            author: the author

        Returns:
            str: the html list item
        """
        view = ScholarView(author)
        name = view.as_html("CEURAUTHOR")
        identifiers = view.identifiers_as_html()
        item = f"""    <li>{name} {identifiers}</li>"""
        return item

    def highwire_tags(self) -> List[Tuple[str, str]]:
        """
        get the Highwire tags of the paper for Google Scholar, see issue 50:
        each tag is a walk from the paper node of the core entities, a walk
        that ends in an empty field gives no tag

        Returns:
            List[Tuple[str, str]]: name and content per tag, in document order
        """
        proceedings = self.proceedings
        event = proceedings.event
        publisher = proceedings.publisher
        tags = [("citation_title", self.paper.title)]
        for author in self.paper.authors:
            name = ", ".join(part for part in [author.name, author.firstName] if part)
            tags.append(("citation_author", name))
        tags.extend(
            [
                ("citation_publication_date", proceedings.pubDate),
                ("citation_publisher", publisher.name if publisher else None),
                ("citation_journal_title", proceedings.title),
                ("citation_conference_title", event.title if event else None),
                ("citation_issn", self.config.series_issn),
                ("citation_doi", self.paper.doi),
                ("citation_firstpage", self.paper.pageStart),
                ("citation_lastpage", self.paper.pageEnd),
                ("citation_pdf_url", self.paper.pdfUrl),
                ("citation_abstract_html_url", self.paper.paperUrl),
            ]
        )
        tags = [(name, str(content)) for name, content in tags if content]
        return tags

    def highwire_as_html(self) -> str:
        """
        get the Highwire tags as meta elements of the head

        Returns:
            str: the html meta elements, one per line
        """
        metas = "\n".join(
            f"""  <meta name="{name}" content="{esc(content)}">""" for name, content in self.highwire_tags()
        )
        return metas

    def as_html(self) -> str:
        """
        get the complete page

        Returns:
            str: the html document
        """
        state = self.state.as_html("the embedded JSON-LD")
        number = esc(self.proceedings.volumeNumber)
        year = esc(self.proceedings.year())
        acronym = esc(self.proceedings.event.acronym)
        paper_id = esc(self.paper.id)
        title = esc(self.paper.title)
        pdf_url = esc(self.paper.pdfUrl)
        stylesheet = esc(self.config.stylesheet)
        fence = Fence.as_html(self.jsonld_text())
        highwire = self.highwire_as_html()
        navigation = self.navigation_as_html()
        pages = PaperView(self.paper).pages_as_html()
        doi = doi_as_html(self.paper.doi)
        authors = "\n".join(self.author_as_html(author) for author in self.paper.authors)
        page = f"""<!DOCTYPE html>
{state}
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" type="text/css" href="{stylesheet}">
  <link rel="alternate" type="application/ld+json" href="index.jsonld">
  <title>CEUR-WS.org/Vol-{number}/{paper_id} - {title}</title>
{highwire}
{fence}
</head>

<body>
  <p class="unobtrusive">Volume: <a href="../">Vol-{number}</a> {acronym} |
    Year: <a href="../../">{year}</a></p>
{navigation}

  <hr>

  <h1><span class="CEURTITLE">{title}</span></h1>
  <ul>
{authors}
  </ul>
  <p>{pages} <a href="{pdf_url}">PDF</a> {doi}</p>

  <hr>

  <embed src="{pdf_url}" style="width:100%;height:100vh" type="application/pdf">
</body>

</html>
"""
        return page

__init__(paper, proceedings, config, jsonld, state)

constructor

Parameters:

Name Type Description Default
paper Paper

the paper

required
proceedings Proceedings

the proceedings the paper is published in

required
config SiteConfig

the site configuration

required
jsonld JsonLd

the JSON-LD support of the research model

required
state PageState

the state to announce

required
Source code in ceursemantify/pages.py
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
def __init__(self, paper: Paper, proceedings: Proceedings, config: SiteConfig, jsonld: JsonLd, state: PageState):
    """
    constructor

    Args:
        paper: the paper
        proceedings: the proceedings the paper is published in
        config: the site configuration
        jsonld: the JSON-LD support of the research model
        state: the state to announce
    """
    self.paper = paper
    self.proceedings = proceedings
    self.config = config
    self.jsonld = jsonld
    self.state = state

as_html()

get the complete page

Returns:

Name Type Description
str str

the html document

Source code in ceursemantify/pages.py
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
    def as_html(self) -> str:
        """
        get the complete page

        Returns:
            str: the html document
        """
        state = self.state.as_html("the embedded JSON-LD")
        number = esc(self.proceedings.volumeNumber)
        year = esc(self.proceedings.year())
        acronym = esc(self.proceedings.event.acronym)
        paper_id = esc(self.paper.id)
        title = esc(self.paper.title)
        pdf_url = esc(self.paper.pdfUrl)
        stylesheet = esc(self.config.stylesheet)
        fence = Fence.as_html(self.jsonld_text())
        highwire = self.highwire_as_html()
        navigation = self.navigation_as_html()
        pages = PaperView(self.paper).pages_as_html()
        doi = doi_as_html(self.paper.doi)
        authors = "\n".join(self.author_as_html(author) for author in self.paper.authors)
        page = f"""<!DOCTYPE html>
{state}
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" type="text/css" href="{stylesheet}">
  <link rel="alternate" type="application/ld+json" href="index.jsonld">
  <title>CEUR-WS.org/Vol-{number}/{paper_id} - {title}</title>
{highwire}
{fence}
</head>

<body>
  <p class="unobtrusive">Volume: <a href="../">Vol-{number}</a> {acronym} |
    Year: <a href="../../">{year}</a></p>
{navigation}

  <hr>

  <h1><span class="CEURTITLE">{title}</span></h1>
  <ul>
{authors}
  </ul>
  <p>{pages} <a href="{pdf_url}">PDF</a> {doi}</p>

  <hr>

  <embed src="{pdf_url}" style="width:100%;height:100vh" type="application/pdf">
</body>

</html>
"""
        return page

author_as_html(author)

get one author with the links to the identifiers

Parameters:

Name Type Description Default
author Scholar

the author

required

Returns:

Name Type Description
str str

the html list item

Source code in ceursemantify/pages.py
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
def author_as_html(self, author: Scholar) -> str:
    """
    get one author with the links to the identifiers

    Args:
        author: the author

    Returns:
        str: the html list item
    """
    view = ScholarView(author)
    name = view.as_html("CEURAUTHOR")
    identifiers = view.identifiers_as_html()
    item = f"""    <li>{name} {identifiers}</li>"""
    return item

highwire_as_html()

get the Highwire tags as meta elements of the head

Returns:

Name Type Description
str str

the html meta elements, one per line

Source code in ceursemantify/pages.py
693
694
695
696
697
698
699
700
701
702
703
def highwire_as_html(self) -> str:
    """
    get the Highwire tags as meta elements of the head

    Returns:
        str: the html meta elements, one per line
    """
    metas = "\n".join(
        f"""  <meta name="{name}" content="{esc(content)}">""" for name, content in self.highwire_tags()
    )
    return metas

highwire_tags()

get the Highwire tags of the paper for Google Scholar, see issue 50: each tag is a walk from the paper node of the core entities, a walk that ends in an empty field gives no tag

Returns:

Type Description
List[Tuple[str, str]]

List[Tuple[str, str]]: name and content per tag, in document order

Source code in ceursemantify/pages.py
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
def highwire_tags(self) -> List[Tuple[str, str]]:
    """
    get the Highwire tags of the paper for Google Scholar, see issue 50:
    each tag is a walk from the paper node of the core entities, a walk
    that ends in an empty field gives no tag

    Returns:
        List[Tuple[str, str]]: name and content per tag, in document order
    """
    proceedings = self.proceedings
    event = proceedings.event
    publisher = proceedings.publisher
    tags = [("citation_title", self.paper.title)]
    for author in self.paper.authors:
        name = ", ".join(part for part in [author.name, author.firstName] if part)
        tags.append(("citation_author", name))
    tags.extend(
        [
            ("citation_publication_date", proceedings.pubDate),
            ("citation_publisher", publisher.name if publisher else None),
            ("citation_journal_title", proceedings.title),
            ("citation_conference_title", event.title if event else None),
            ("citation_issn", self.config.series_issn),
            ("citation_doi", self.paper.doi),
            ("citation_firstpage", self.paper.pageStart),
            ("citation_lastpage", self.paper.pageEnd),
            ("citation_pdf_url", self.paper.pdfUrl),
            ("citation_abstract_html_url", self.paper.paperUrl),
        ]
    )
    tags = [(name, str(content)) for name, content in tags if content]
    return tags

jsonld_text()

get the pretty printed JSON-LD of the paper

Returns:

Name Type Description
str str

the JSON-LD

Source code in ceursemantify/pages.py
613
614
615
616
617
618
619
620
621
def jsonld_text(self) -> str:
    """
    get the pretty printed JSON-LD of the paper

    Returns:
        str: the JSON-LD
    """
    text = self.jsonld.as_text(self.paper)
    return text

navigation_as_html()

get the links to the previous and the next paper of the volume

Returns:

Name Type Description
str str

the html paragraph

Source code in ceursemantify/pages.py
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
def navigation_as_html(self) -> str:
    """
    get the links to the previous and the next paper of the volume

    Returns:
        str: the html paragraph
    """
    paper_ids = [paper.id for paper in self.proceedings.papers if paper.id]
    position = paper_ids.index(self.paper.id)
    parts = []
    if position > 0:
        previous_id = esc(paper_ids[position - 1])
        parts.append(f"""<a href="../{previous_id}/" rel="prev">&laquo; {previous_id}</a>""")
    parts.append(esc(self.paper.id))
    if position < len(paper_ids) - 1:
        next_id = esc(paper_ids[position + 1])
        parts.append(f"""<a href="../{next_id}/" rel="next">{next_id} &raquo;</a>""")
    links = " | ".join(parts)
    navigation = f"""  <p>Paper: {links}</p>"""
    return navigation

PaperView

html view of a paper in the table of contents

Source code in ceursemantify/pages.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
class PaperView:
    """
    html view of a paper in the table of contents
    """

    def __init__(self, paper: Paper):
        """
        constructor

        Args:
            paper: the paper
        """
        self.paper = paper

    def pages_as_html(self) -> str:
        """
        get the page range of the paper

        Returns:
            str: the html span, empty if the paper has no pages
        """
        pages_html = ""
        if self.paper.pageStart:
            page_start = esc(self.paper.pageStart)
            page_end = esc(self.paper.pageEnd)
            pages_html = f"""<span class="CEURPAGES">{page_start}-{page_end}</span>"""
        return pages_html

    def as_html(self) -> str:
        """
        get the table of contents entry of the paper

        Returns:
            str: the html list item
        """
        paper_id = esc(self.paper.id)
        pdf_url = esc(self.paper.pdfUrl)
        title = esc(self.paper.title)
        pages = self.pages_as_html()
        doi = doi_as_html(self.paper.doi)
        authors = ",\n        ".join(ScholarView(author).as_html("CEURAUTHOR") for author in self.paper.authors)
        # the line breaks after the title and its pages as in the Vol-XXX template, see issue 44
        if pages:
            pages = f" {pages}"
        if self.paper.id:
            item = f"""      <li id="{paper_id}"><a href="{paper_id}/">
          <span class="CEURTITLE">{title}</span></a>{pages}<br>
        <a href="{pdf_url}">PDF</a> {doi}<br>
        {authors}
      </li>"""
        else:
            item = f"""      <li><a href="{pdf_url}">
          <span class="CEURTITLE">{title}</span></a>{pages}<br>
        {doi}<br>
        {authors}
      </li>"""
        return item

__init__(paper)

constructor

Parameters:

Name Type Description Default
paper Paper

the paper

required
Source code in ceursemantify/pages.py
189
190
191
192
193
194
195
196
def __init__(self, paper: Paper):
    """
    constructor

    Args:
        paper: the paper
    """
    self.paper = paper

as_html()

get the table of contents entry of the paper

Returns:

Name Type Description
str str

the html list item

Source code in ceursemantify/pages.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def as_html(self) -> str:
    """
    get the table of contents entry of the paper

    Returns:
        str: the html list item
    """
    paper_id = esc(self.paper.id)
    pdf_url = esc(self.paper.pdfUrl)
    title = esc(self.paper.title)
    pages = self.pages_as_html()
    doi = doi_as_html(self.paper.doi)
    authors = ",\n        ".join(ScholarView(author).as_html("CEURAUTHOR") for author in self.paper.authors)
    # the line breaks after the title and its pages as in the Vol-XXX template, see issue 44
    if pages:
        pages = f" {pages}"
    if self.paper.id:
        item = f"""      <li id="{paper_id}"><a href="{paper_id}/">
      <span class="CEURTITLE">{title}</span></a>{pages}<br>
    <a href="{pdf_url}">PDF</a> {doi}<br>
    {authors}
  </li>"""
    else:
        item = f"""      <li><a href="{pdf_url}">
      <span class="CEURTITLE">{title}</span></a>{pages}<br>
    {doi}<br>
    {authors}
  </li>"""
    return item

pages_as_html()

get the page range of the paper

Returns:

Name Type Description
str str

the html span, empty if the paper has no pages

Source code in ceursemantify/pages.py
198
199
200
201
202
203
204
205
206
207
208
209
210
def pages_as_html(self) -> str:
    """
    get the page range of the paper

    Returns:
        str: the html span, empty if the paper has no pages
    """
    pages_html = ""
    if self.paper.pageStart:
        page_start = esc(self.paper.pageStart)
        page_end = esc(self.paper.pageEnd)
        pages_html = f"""<span class="CEURPAGES">{page_start}-{page_end}</span>"""
    return pages_html

ScholarView

html view of a scholar

Source code in ceursemantify/pages.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
class ScholarView:
    """
    html view of a scholar
    """

    # label, field of the scholar and formatter URI as in CrSchema
    identifier_formatters = [
        ("ORCID", "orcid", "https://orcid.org/$1"),
        ("dblp", "dblpId", "https://dblp.org/pid/$1"),
        ("Wikidata", "wikiDataId", "https://www.wikidata.org/wiki/$1"),
        ("GND", "gndId", "https://d-nb.info/gnd/$1"),
    ]

    def __init__(self, scholar: Scholar):
        """
        constructor

        Args:
            scholar: the scholar
        """
        self.scholar = scholar

    def as_html(self, css_class: str) -> str:
        """
        get the name of the scholar as a span

        Args:
            css_class: the CEUR-WS class of the span e.g. CEURAUTHOR

        Returns:
            str: the html span
        """
        name = esc(self.scholar.full_name())
        # the ORCID as itemid of the span, the markup of the Vol-XXX template that dblp reads, see issue 51
        orcid_attributes = ""
        if self.scholar.orcid:
            orcid = esc(self.scholar.orcid)
            orcid_attributes = (
                f""" itemscope itemtype="https://schema.org/Person" itemid="https://orcid.org/{orcid}\""""
            )
        span = f"""<span class="{css_class}"{orcid_attributes}>{name}</span>"""
        return span

    def identifiers_as_html(self) -> str:
        """
        get the links to the identifiers of the scholar

        Returns:
            str: the html links, empty if the scholar has no identifiers
        """
        links = []
        for label, field_name, formatter in self.identifier_formatters:
            identifier = getattr(self.scholar, field_name)
            if identifier:
                url = esc(formatter.replace("$1", identifier))
                links.append(f"""<a href="{url}">{label}</a>""")
        identifiers = " ".join(links)
        return identifiers

__init__(scholar)

constructor

Parameters:

Name Type Description Default
scholar Scholar

the scholar

required
Source code in ceursemantify/pages.py
137
138
139
140
141
142
143
144
def __init__(self, scholar: Scholar):
    """
    constructor

    Args:
        scholar: the scholar
    """
    self.scholar = scholar

as_html(css_class)

get the name of the scholar as a span

Parameters:

Name Type Description Default
css_class str

the CEUR-WS class of the span e.g. CEURAUTHOR

required

Returns:

Name Type Description
str str

the html span

Source code in ceursemantify/pages.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
def as_html(self, css_class: str) -> str:
    """
    get the name of the scholar as a span

    Args:
        css_class: the CEUR-WS class of the span e.g. CEURAUTHOR

    Returns:
        str: the html span
    """
    name = esc(self.scholar.full_name())
    # the ORCID as itemid of the span, the markup of the Vol-XXX template that dblp reads, see issue 51
    orcid_attributes = ""
    if self.scholar.orcid:
        orcid = esc(self.scholar.orcid)
        orcid_attributes = (
            f""" itemscope itemtype="https://schema.org/Person" itemid="https://orcid.org/{orcid}\""""
        )
    span = f"""<span class="{css_class}"{orcid_attributes}>{name}</span>"""
    return span

identifiers_as_html()

get the links to the identifiers of the scholar

Returns:

Name Type Description
str str

the html links, empty if the scholar has no identifiers

Source code in ceursemantify/pages.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
def identifiers_as_html(self) -> str:
    """
    get the links to the identifiers of the scholar

    Returns:
        str: the html links, empty if the scholar has no identifiers
    """
    links = []
    for label, field_name, formatter in self.identifier_formatters:
        identifier = getattr(self.scholar, field_name)
        if identifier:
            url = esc(formatter.replace("$1", identifier))
            links.append(f"""<a href="{url}">{label}</a>""")
    identifiers = " ".join(links)
    return identifiers

VolumePage

the page of the proceedings of one volume

Source code in ceursemantify/pages.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
class VolumePage:
    """
    the page of the proceedings of one volume
    """

    def __init__(
        self, proceedings: Proceedings, config: SiteConfig, jsonld: JsonLd, state: PageState, in_place: bool = False
    ):
        """
        constructor

        Args:
            proceedings: the proceedings
            config: the site configuration
            jsonld: the JSON-LD support of the research model
            state: the state to announce
            in_place: if True the page replaces the volume page of the original site, see issue 44
        """
        self.proceedings = proceedings
        self.config = config
        self.jsonld = jsonld
        self.state = state
        self.in_place = in_place

    def original_url(self) -> str:
        """
        get the url of the volume at the original site

        Returns:
            str: the url
        """
        original_url = f"{self.config.original_url}/Vol-{self.proceedings.volumeNumber}/"
        return original_url

    def page_url(self) -> str:
        """
        get the url the page is served from

        Returns:
            str: the url
        """
        if self.in_place:
            page_url = self.original_url()
        else:
            page_url = f"{self.config.base_url}/{self.proceedings.year()}/Vol-{self.proceedings.volumeNumber}/"
        return page_url

    def jsonld_text(self) -> str:
        """
        get the pretty printed JSON-LD of the proceedings

        Returns:
            str: the JSON-LD
        """
        text = self.jsonld.as_text(self.proceedings)
        return text

    def head_as_html(self) -> str:
        """
        get the head of the page

        Returns:
            str: the html head element
        """
        number = esc(self.proceedings.volumeNumber)
        title = esc(self.proceedings.title)
        stylesheet = esc(self.config.stylesheet)
        fence = Fence.as_html(self.jsonld_text())
        head = f"""<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" type="text/css" href="{stylesheet}">
  <link rel="alternate" type="application/ld+json" href="index.jsonld">
  <title>CEUR-WS.org/Vol-{number} - {title}</title>
{fence}
</head>"""
        return head

    def links_as_html(self) -> str:
        """
        get the links to the original volume, its copy and the year; a page
        in place is the original and has no year tree, so it has no such links

        Returns:
            str: the html paragraph, empty in place
        """
        if self.in_place:
            return ""
        number = esc(self.proceedings.volumeNumber)
        year = esc(self.proceedings.year())
        original_url = esc(self.original_url())
        base_url = esc(self.config.base_url)
        links = f"""  <p class="unobtrusive">Original: <a href="{original_url}">{original_url}</a> |
    Mirror: <a href="/Vol-{number}/">{base_url}/Vol-{number}/</a> |
    Year: <a href="../">{year}</a></p>"""
        return links

    # license URL of the record, name and abbreviation as in the copyright text of the Vol-XXX template
    license_names = {
        "https://creativecommons.org/licenses/by/4.0/": (
            "the Creative Commons License Attribution 4.0 International",
            "CC BY 4.0",
        ),
    }

    def license_as_html(self) -> str:
        """
        get the license of the proceedings as in the copyright text of the Vol-XXX template, see issue 44

        Returns:
            str: the license name with the abbreviation linked to the license, the bare URL for an unknown license
        """
        license_url = self.proceedings.license
        url = esc(license_url)
        name, abbreviation = self.license_names.get(license_url, (None, None))
        if name:
            license_html = f"""{name} <a href="{url}">(<span class="CEURLIC">{abbreviation}</span>)</a>"""
        else:
            license_html = f"""<a href="{url}"><span class="CEURLIC">{url}</span></a>"""
        return license_html

    def header_as_html(self) -> str:
        """
        get the header with volume number, URN and copyright

        Returns:
            str: the html table
        """
        number = esc(self.proceedings.volumeNumber)
        urn = esc(self.proceedings.urn)
        pub_year = esc(self.proceedings.pubYear)
        license_html = self.license_as_html()
        doi = doi_as_html(self.proceedings.doi)
        if doi:
            doi = f""" <br>
          {doi}"""
        header = f"""  <table style="border: 0; border-spacing: 0; border-collapse: collapse; width: 95%">
    <tbody>
      <tr>
        <td style="text-align: left; vertical-align: middle">
          <a href="../"><div id="CEURWSLOGO"></div></a>
        </td>
        <td style="text-align: right; vertical-align: middle">
          <span class="CEURVOLNR">Vol-{number}</span> <br>
          <span class="CEURURN">{urn}</span>{doi}
          <p class="unobtrusive copyright" style="text-align: justify">Copyright &copy; {pub_year} for
            the individual papers by the papers' authors.
            Copyright &copy; <span class="CEURPUBYEAR">{pub_year}</span> for the volume
            as a collection by its editors.
            This volume and its papers are published under {license_html}.
          </p>
        </td>
      </tr>
    </tbody>
  </table>"""
        return header

    def acronym_as_html(self) -> str:
        """
        get the acronym of the event, linked to its homepage if there is one

        Returns:
            str: the html span or link
        """
        event = self.proceedings.event
        acronym = esc(event.acronym)
        acronym_html = f"""<span class="CEURVOLACRONYM">{acronym}</span>"""
        if event.homepage:
            homepage = esc(event.homepage)
            acronym_html = f"""<a href="{homepage}">{acronym_html}</a>"""
        return acronym_html

    def colocated_as_html(self) -> str:
        """
        get the event the event of the proceedings is co-located with

        Returns:
            str: the html text, empty if the event is not co-located
        """
        colocated_html = ""
        colocated_with = self.proceedings.event.colocated_with
        if colocated_with:
            acronym = esc(colocated_with.acronym)
            colocated_html = f"""<br>
    co-located with <span class="CEURCOLOCATED">{acronym}</span>"""
        return colocated_html

    def title_as_html(self) -> str:
        """
        get the headings with acronym, title, location and time

        Returns:
            str: the html headings
        """
        acronym = self.acronym_as_html()
        title = esc(self.proceedings.title)
        colocated = self.colocated_as_html()
        location = esc(self.proceedings.event.location)
        # the event title below the acronym as in the Vol-XXX template, see issue 54
        if self.proceedings.event.title:
            event_title = esc(self.proceedings.event.title)
            acronym = f"""{acronym}<br>
    <span class="CEURVOLTITLE">{event_title}</span>"""
        title_html = f"""  <h1>{acronym}</h1>

  <h2>
    <span class="CEURFULLTITLE">{title}</span>{colocated}
  </h2>
  <h3><span class="CEURLOCTIME">{location}</span>.</h3>"""
        return title_html

    def editors_as_html(self) -> str:
        """
        get the editors of the proceedings

        Returns:
            str: the html paragraph and heading
        """
        editors = "<br>\n    ".join(ScholarView(editor).as_html("CEURVOLEDITOR") for editor in self.proceedings.editors)
        editors_html = f"""  <p><b>Edited by</b></p>
  <h3>
    {editors}
  </h3>"""
        return editors_html

    def toc_as_html(self) -> str:
        """
        get the table of contents

        Returns:
            str: the html division
        """
        summary = self.summary_as_html()
        # a CEURSESSION heading opens a new list wherever the session of the papers changes, see issue 54
        lists = []
        session = None
        items = []
        session_number = 0
        for paper in self.proceedings.papers:
            if paper.session != session:
                if items:
                    lists.append("    <ul>\n" + "\n".join(items) + "\n    </ul>")
                    items = []
                session = paper.session
                if session:
                    session_number += 1
                    lists.append(
                        f"""    <h3><span id="session{session_number}" class="CEURSESSION">{esc(session)}</span></h3>"""
                    )
            items.append(PaperView(paper).as_html())
        lists.append("    <ul>\n" + "\n".join(items) + "\n    </ul>")
        papers = "\n".join(lists)
        toc = f"""  <div class="CEURTOC">
    <h2>Table of Contents</h2>
{summary}{papers}
  </div>"""
        return toc

    def summary_as_html(self) -> str:
        """
        get the summary sentence with the numbers of submitted and accepted papers as in the Vol-XXX template,
        see issue 54

        Returns:
            str: the html paragraph, empty if the proceedings have neither number
        """
        summary_html = ""
        submitted = self.proceedings.submittedPapers
        accepted = self.proceedings.acceptedPapers
        if submitted is not None or accepted is not None:
            sentences = []
            if submitted is not None:
                sentences.append(
                    f"""There were <span class="CEURSUBMITTEDPAPERS">{esc(submitted)}</span> papers"""
                    """ submitted for peer-review to this workshop."""
                )
            if accepted is not None:
                prefix = "Out of these, " if submitted is not None else ""
                sentences.append(
                    f"""{prefix}<span class="CEURACCEPTEDPAPERS">{esc(accepted)}</span> papers"""
                    """ were accepted for this volume."""
                )
            summary_html = "    <p>Summary: " + "\n      ".join(sentences) + "</p>\n"
        return summary_html

    def footer_as_html(self) -> str:
        """
        get the footer with publication date, series and checker link

        Returns:
            str: the html span
        """
        pub_date = esc(self.proceedings.pubDate)
        series_title = esc(self.config.series_title)
        series_issn = esc(self.config.series_issn)
        checked_url = quote(self.page_url(), safe="")
        validator_url = esc(f"{self.config.validator_url}?doc={checked_url}")
        footer = f"""  <span class="unobtrusive">
    <span class="CEURPUBDATE">{pub_date}</span>: published on {series_title} (CEUR-WS.org, ISSN {series_issn})
    |<a href="{validator_url}">valid HTML5</a>|
  </span>"""
        return footer

    def as_html(self) -> str:
        """
        get the complete page

        Returns:
            str: the html document
        """
        state = self.state.as_html("the embedded JSON-LD")
        head = self.head_as_html()
        links = self.links_as_html()
        header = self.header_as_html()
        title = self.title_as_html()
        editors = self.editors_as_html()
        toc = self.toc_as_html()
        footer = self.footer_as_html()
        page = f"""<!DOCTYPE html>
{state}
<html lang="en">

{head}

<body>
{links}

{header}

  <hr>

{title}

{editors}

  <hr>

{toc}

  <hr>
{footer}
</body>

</html>
"""
        return page

__init__(proceedings, config, jsonld, state, in_place=False)

constructor

Parameters:

Name Type Description Default
proceedings Proceedings

the proceedings

required
config SiteConfig

the site configuration

required
jsonld JsonLd

the JSON-LD support of the research model

required
state PageState

the state to announce

required
in_place bool

if True the page replaces the volume page of the original site, see issue 44

False
Source code in ceursemantify/pages.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def __init__(
    self, proceedings: Proceedings, config: SiteConfig, jsonld: JsonLd, state: PageState, in_place: bool = False
):
    """
    constructor

    Args:
        proceedings: the proceedings
        config: the site configuration
        jsonld: the JSON-LD support of the research model
        state: the state to announce
        in_place: if True the page replaces the volume page of the original site, see issue 44
    """
    self.proceedings = proceedings
    self.config = config
    self.jsonld = jsonld
    self.state = state
    self.in_place = in_place

acronym_as_html()

get the acronym of the event, linked to its homepage if there is one

Returns:

Name Type Description
str str

the html span or link

Source code in ceursemantify/pages.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def acronym_as_html(self) -> str:
    """
    get the acronym of the event, linked to its homepage if there is one

    Returns:
        str: the html span or link
    """
    event = self.proceedings.event
    acronym = esc(event.acronym)
    acronym_html = f"""<span class="CEURVOLACRONYM">{acronym}</span>"""
    if event.homepage:
        homepage = esc(event.homepage)
        acronym_html = f"""<a href="{homepage}">{acronym_html}</a>"""
    return acronym_html

as_html()

get the complete page

Returns:

Name Type Description
str str

the html document

Source code in ceursemantify/pages.py
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
    def as_html(self) -> str:
        """
        get the complete page

        Returns:
            str: the html document
        """
        state = self.state.as_html("the embedded JSON-LD")
        head = self.head_as_html()
        links = self.links_as_html()
        header = self.header_as_html()
        title = self.title_as_html()
        editors = self.editors_as_html()
        toc = self.toc_as_html()
        footer = self.footer_as_html()
        page = f"""<!DOCTYPE html>
{state}
<html lang="en">

{head}

<body>
{links}

{header}

  <hr>

{title}

{editors}

  <hr>

{toc}

  <hr>
{footer}
</body>

</html>
"""
        return page

colocated_as_html()

get the event the event of the proceedings is co-located with

Returns:

Name Type Description
str str

the html text, empty if the event is not co-located

Source code in ceursemantify/pages.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
def colocated_as_html(self) -> str:
    """
    get the event the event of the proceedings is co-located with

    Returns:
        str: the html text, empty if the event is not co-located
    """
    colocated_html = ""
    colocated_with = self.proceedings.event.colocated_with
    if colocated_with:
        acronym = esc(colocated_with.acronym)
        colocated_html = f"""<br>
co-located with <span class="CEURCOLOCATED">{acronym}</span>"""
    return colocated_html

editors_as_html()

get the editors of the proceedings

Returns:

Name Type Description
str str

the html paragraph and heading

Source code in ceursemantify/pages.py
454
455
456
457
458
459
460
461
462
463
464
465
466
  def editors_as_html(self) -> str:
      """
      get the editors of the proceedings

      Returns:
          str: the html paragraph and heading
      """
      editors = "<br>\n    ".join(ScholarView(editor).as_html("CEURVOLEDITOR") for editor in self.proceedings.editors)
      editors_html = f"""  <p><b>Edited by</b></p>
<h3>
  {editors}
</h3>"""
      return editors_html

footer_as_html()

get the footer with publication date, series and checker link

Returns:

Name Type Description
str str

the html span

Source code in ceursemantify/pages.py
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
  def footer_as_html(self) -> str:
      """
      get the footer with publication date, series and checker link

      Returns:
          str: the html span
      """
      pub_date = esc(self.proceedings.pubDate)
      series_title = esc(self.config.series_title)
      series_issn = esc(self.config.series_issn)
      checked_url = quote(self.page_url(), safe="")
      validator_url = esc(f"{self.config.validator_url}?doc={checked_url}")
      footer = f"""  <span class="unobtrusive">
  <span class="CEURPUBDATE">{pub_date}</span>: published on {series_title} (CEUR-WS.org, ISSN {series_issn})
  |<a href="{validator_url}">valid HTML5</a>|
</span>"""
      return footer

head_as_html()

get the head of the page

Returns:

Name Type Description
str str

the html head element

Source code in ceursemantify/pages.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
    def head_as_html(self) -> str:
        """
        get the head of the page

        Returns:
            str: the html head element
        """
        number = esc(self.proceedings.volumeNumber)
        title = esc(self.proceedings.title)
        stylesheet = esc(self.config.stylesheet)
        fence = Fence.as_html(self.jsonld_text())
        head = f"""<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" type="text/css" href="{stylesheet}">
  <link rel="alternate" type="application/ld+json" href="index.jsonld">
  <title>CEUR-WS.org/Vol-{number} - {title}</title>
{fence}
</head>"""
        return head

header_as_html()

get the header with volume number, URN and copyright

Returns:

Name Type Description
str str

the html table

Source code in ceursemantify/pages.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
  def header_as_html(self) -> str:
      """
      get the header with volume number, URN and copyright

      Returns:
          str: the html table
      """
      number = esc(self.proceedings.volumeNumber)
      urn = esc(self.proceedings.urn)
      pub_year = esc(self.proceedings.pubYear)
      license_html = self.license_as_html()
      doi = doi_as_html(self.proceedings.doi)
      if doi:
          doi = f""" <br>
        {doi}"""
      header = f"""  <table style="border: 0; border-spacing: 0; border-collapse: collapse; width: 95%">
  <tbody>
    <tr>
      <td style="text-align: left; vertical-align: middle">
        <a href="../"><div id="CEURWSLOGO"></div></a>
      </td>
      <td style="text-align: right; vertical-align: middle">
        <span class="CEURVOLNR">Vol-{number}</span> <br>
        <span class="CEURURN">{urn}</span>{doi}
        <p class="unobtrusive copyright" style="text-align: justify">Copyright &copy; {pub_year} for
          the individual papers by the papers' authors.
          Copyright &copy; <span class="CEURPUBYEAR">{pub_year}</span> for the volume
          as a collection by its editors.
          This volume and its papers are published under {license_html}.
        </p>
      </td>
    </tr>
  </tbody>
</table>"""
      return header

jsonld_text()

get the pretty printed JSON-LD of the proceedings

Returns:

Name Type Description
str str

the JSON-LD

Source code in ceursemantify/pages.py
290
291
292
293
294
295
296
297
298
def jsonld_text(self) -> str:
    """
    get the pretty printed JSON-LD of the proceedings

    Returns:
        str: the JSON-LD
    """
    text = self.jsonld.as_text(self.proceedings)
    return text

license_as_html()

get the license of the proceedings as in the copyright text of the Vol-XXX template, see issue 44

Returns:

Name Type Description
str str

the license name with the abbreviation linked to the license, the bare URL for an unknown license

Source code in ceursemantify/pages.py
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
def license_as_html(self) -> str:
    """
    get the license of the proceedings as in the copyright text of the Vol-XXX template, see issue 44

    Returns:
        str: the license name with the abbreviation linked to the license, the bare URL for an unknown license
    """
    license_url = self.proceedings.license
    url = esc(license_url)
    name, abbreviation = self.license_names.get(license_url, (None, None))
    if name:
        license_html = f"""{name} <a href="{url}">(<span class="CEURLIC">{abbreviation}</span>)</a>"""
    else:
        license_html = f"""<a href="{url}"><span class="CEURLIC">{url}</span></a>"""
    return license_html

get the links to the original volume, its copy and the year; a page in place is the original and has no year tree, so it has no such links

Returns:

Name Type Description
str str

the html paragraph, empty in place

Source code in ceursemantify/pages.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
def links_as_html(self) -> str:
    """
    get the links to the original volume, its copy and the year; a page
    in place is the original and has no year tree, so it has no such links

    Returns:
        str: the html paragraph, empty in place
    """
    if self.in_place:
        return ""
    number = esc(self.proceedings.volumeNumber)
    year = esc(self.proceedings.year())
    original_url = esc(self.original_url())
    base_url = esc(self.config.base_url)
    links = f"""  <p class="unobtrusive">Original: <a href="{original_url}">{original_url}</a> |
Mirror: <a href="/Vol-{number}/">{base_url}/Vol-{number}/</a> |
Year: <a href="../">{year}</a></p>"""
    return links

original_url()

get the url of the volume at the original site

Returns:

Name Type Description
str str

the url

Source code in ceursemantify/pages.py
267
268
269
270
271
272
273
274
275
def original_url(self) -> str:
    """
    get the url of the volume at the original site

    Returns:
        str: the url
    """
    original_url = f"{self.config.original_url}/Vol-{self.proceedings.volumeNumber}/"
    return original_url

page_url()

get the url the page is served from

Returns:

Name Type Description
str str

the url

Source code in ceursemantify/pages.py
277
278
279
280
281
282
283
284
285
286
287
288
def page_url(self) -> str:
    """
    get the url the page is served from

    Returns:
        str: the url
    """
    if self.in_place:
        page_url = self.original_url()
    else:
        page_url = f"{self.config.base_url}/{self.proceedings.year()}/Vol-{self.proceedings.volumeNumber}/"
    return page_url

summary_as_html()

get the summary sentence with the numbers of submitted and accepted papers as in the Vol-XXX template, see issue 54

Returns:

Name Type Description
str str

the html paragraph, empty if the proceedings have neither number

Source code in ceursemantify/pages.py
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
def summary_as_html(self) -> str:
    """
    get the summary sentence with the numbers of submitted and accepted papers as in the Vol-XXX template,
    see issue 54

    Returns:
        str: the html paragraph, empty if the proceedings have neither number
    """
    summary_html = ""
    submitted = self.proceedings.submittedPapers
    accepted = self.proceedings.acceptedPapers
    if submitted is not None or accepted is not None:
        sentences = []
        if submitted is not None:
            sentences.append(
                f"""There were <span class="CEURSUBMITTEDPAPERS">{esc(submitted)}</span> papers"""
                """ submitted for peer-review to this workshop."""
            )
        if accepted is not None:
            prefix = "Out of these, " if submitted is not None else ""
            sentences.append(
                f"""{prefix}<span class="CEURACCEPTEDPAPERS">{esc(accepted)}</span> papers"""
                """ were accepted for this volume."""
            )
        summary_html = "    <p>Summary: " + "\n      ".join(sentences) + "</p>\n"
    return summary_html

title_as_html()

get the headings with acronym, title, location and time

Returns:

Name Type Description
str str

the html headings

Source code in ceursemantify/pages.py
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
  def title_as_html(self) -> str:
      """
      get the headings with acronym, title, location and time

      Returns:
          str: the html headings
      """
      acronym = self.acronym_as_html()
      title = esc(self.proceedings.title)
      colocated = self.colocated_as_html()
      location = esc(self.proceedings.event.location)
      # the event title below the acronym as in the Vol-XXX template, see issue 54
      if self.proceedings.event.title:
          event_title = esc(self.proceedings.event.title)
          acronym = f"""{acronym}<br>
  <span class="CEURVOLTITLE">{event_title}</span>"""
      title_html = f"""  <h1>{acronym}</h1>

<h2>
  <span class="CEURFULLTITLE">{title}</span>{colocated}
</h2>
<h3><span class="CEURLOCTIME">{location}</span>.</h3>"""
      return title_html

toc_as_html()

get the table of contents

Returns:

Name Type Description
str str

the html division

Source code in ceursemantify/pages.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
    def toc_as_html(self) -> str:
        """
        get the table of contents

        Returns:
            str: the html division
        """
        summary = self.summary_as_html()
        # a CEURSESSION heading opens a new list wherever the session of the papers changes, see issue 54
        lists = []
        session = None
        items = []
        session_number = 0
        for paper in self.proceedings.papers:
            if paper.session != session:
                if items:
                    lists.append("    <ul>\n" + "\n".join(items) + "\n    </ul>")
                    items = []
                session = paper.session
                if session:
                    session_number += 1
                    lists.append(
                        f"""    <h3><span id="session{session_number}" class="CEURSESSION">{esc(session)}</span></h3>"""
                    )
            items.append(PaperView(paper).as_html())
        lists.append("    <ul>\n" + "\n".join(items) + "\n    </ul>")
        papers = "\n".join(lists)
        toc = f"""  <div class="CEURTOC">
    <h2>Table of Contents</h2>
{summary}{papers}
  </div>"""
        return toc

YearEntry

the entry of one volume on the page of its year

Source code in ceursemantify/pages.py
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
class YearEntry:
    """
    the entry of one volume on the page of its year
    """

    def __init__(self, proceedings: Proceedings, config: SiteConfig):
        """
        constructor

        Args:
            proceedings: the proceedings
            config: the site configuration
        """
        self.proceedings = proceedings
        self.config = config

    def as_html(self) -> str:
        """
        get the entry

        Returns:
            str: the html division
        """
        number = esc(self.proceedings.volumeNumber)
        acronym = esc(self.proceedings.event.acronym)
        title = esc(self.proceedings.title)
        location = esc(self.proceedings.event.location)
        editors = esc(", ".join(editor.full_name() for editor in self.proceedings.editors))
        pub_date = esc(self.proceedings.pubDate)
        original_url = esc(f"{self.config.original_url}/Vol-{self.proceedings.volumeNumber}/")
        urn = esc(self.proceedings.urn)
        archive = esc(self.proceedings.archive)
        entry = f"""  <div class="CEURVOLUME" id="Vol-{number}">
    <h2><a href="Vol-{number}/">Vol-{number}</a> {acronym}</h2>
    <p>{title},
      {location}.<br>
      Edited by: {editors}<br>
      Published on CEUR-WS: {pub_date}<br>
      ONLINE: <a href="{original_url}">{original_url}</a><br>
      URN: <a href="https://nbn-resolving.org/{urn}">{urn}</a><br>
      ARCHIVE: <a href="{archive}">{archive}</a>
    </p>
  </div>"""
        return entry

__init__(proceedings, config)

constructor

Parameters:

Name Type Description Default
proceedings Proceedings

the proceedings

required
config SiteConfig

the site configuration

required
Source code in ceursemantify/pages.py
847
848
849
850
851
852
853
854
855
856
def __init__(self, proceedings: Proceedings, config: SiteConfig):
    """
    constructor

    Args:
        proceedings: the proceedings
        config: the site configuration
    """
    self.proceedings = proceedings
    self.config = config

as_html()

get the entry

Returns:

Name Type Description
str str

the html division

Source code in ceursemantify/pages.py
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
  def as_html(self) -> str:
      """
      get the entry

      Returns:
          str: the html division
      """
      number = esc(self.proceedings.volumeNumber)
      acronym = esc(self.proceedings.event.acronym)
      title = esc(self.proceedings.title)
      location = esc(self.proceedings.event.location)
      editors = esc(", ".join(editor.full_name() for editor in self.proceedings.editors))
      pub_date = esc(self.proceedings.pubDate)
      original_url = esc(f"{self.config.original_url}/Vol-{self.proceedings.volumeNumber}/")
      urn = esc(self.proceedings.urn)
      archive = esc(self.proceedings.archive)
      entry = f"""  <div class="CEURVOLUME" id="Vol-{number}">
  <h2><a href="Vol-{number}/">Vol-{number}</a> {acronym}</h2>
  <p>{title},
    {location}.<br>
    Edited by: {editors}<br>
    Published on CEUR-WS: {pub_date}<br>
    ONLINE: <a href="{original_url}">{original_url}</a><br>
    URN: <a href="https://nbn-resolving.org/{urn}">{urn}</a><br>
    ARCHIVE: <a href="{archive}">{archive}</a>
  </p>
</div>"""
      return entry

YearPage

the page of one year with the volumes whose event started in that year

Source code in ceursemantify/pages.py
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
class YearPage:
    """
    the page of one year with the volumes whose event started in that year
    """

    def __init__(
        self, year: int, proceedings_list: List[Proceedings], years: List[int], config: SiteConfig, state: PageState
    ):
        """
        constructor

        Args:
            year: the year
            proceedings_list: the proceedings of the year
            years: all years that have a page
            config: the site configuration
            state: the state to announce
        """
        self.year = year
        self.proceedings_list = proceedings_list
        self.years = years
        self.config = config
        self.state = state

    def navigation_as_html(self) -> str:
        """
        get the links to the previous and the next year, where those exist

        Returns:
            str: the html paragraph
        """
        parts = []
        previous_year = self.year - 1
        next_year = self.year + 1
        if previous_year in self.years:
            parts.append(f"""<a href="../{previous_year}/" rel="prev">&laquo; {previous_year}</a>""")
        parts.append(str(self.year))
        if next_year in self.years:
            parts.append(f"""<a href="../{next_year}/" rel="next">{next_year} &raquo;</a>""")
        links = " | ".join(parts)
        navigation = f"""  <p>Year: {links}</p>"""
        return navigation

    def entries_as_html(self) -> str:
        """
        get the entries of the volumes, the highest volume number first

        Returns:
            str: the html divisions
        """
        by_number = {proceedings.volumeNumber: proceedings for proceedings in self.proceedings_list}
        numbers = sorted(by_number.keys(), reverse=True)
        entries = "\n".join(YearEntry(by_number[number], self.config).as_html() for number in numbers)
        return entries

    def as_html(self) -> str:
        """
        get the complete page

        Returns:
            str: the html document
        """
        year = esc(self.year)
        count = len(self.proceedings_list)
        state = self.state.as_html(f"the JSON-LD of {count} volumes")
        stylesheet = esc(self.config.stylesheet)
        series_title = esc(self.config.series_title)
        original_url = esc(self.config.original_url)
        base_url = esc(self.config.base_url)
        header = Header(self.config).as_html(f"{self.config.series_title} of events in {self.year}")
        navigation = self.navigation_as_html()
        entries = self.entries_as_html()
        page = f"""<!DOCTYPE html>
{state}
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" type="text/css" href="{stylesheet}">
  <title>CEUR-WS.org - {series_title} of events in {year}</title>
</head>

<body>
{header}
{navigation}
  <p class="unobtrusive">Original: <a href="{original_url}/">{original_url}/</a> |
    Mirror: <a href="/">{base_url}/</a></p>

{entries}
</body>

</html>
"""
        return page

__init__(year, proceedings_list, years, config, state)

constructor

Parameters:

Name Type Description Default
year int

the year

required
proceedings_list List[Proceedings]

the proceedings of the year

required
years List[int]

all years that have a page

required
config SiteConfig

the site configuration

required
state PageState

the state to announce

required
Source code in ceursemantify/pages.py
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
def __init__(
    self, year: int, proceedings_list: List[Proceedings], years: List[int], config: SiteConfig, state: PageState
):
    """
    constructor

    Args:
        year: the year
        proceedings_list: the proceedings of the year
        years: all years that have a page
        config: the site configuration
        state: the state to announce
    """
    self.year = year
    self.proceedings_list = proceedings_list
    self.years = years
    self.config = config
    self.state = state

as_html()

get the complete page

Returns:

Name Type Description
str str

the html document

Source code in ceursemantify/pages.py
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
    def as_html(self) -> str:
        """
        get the complete page

        Returns:
            str: the html document
        """
        year = esc(self.year)
        count = len(self.proceedings_list)
        state = self.state.as_html(f"the JSON-LD of {count} volumes")
        stylesheet = esc(self.config.stylesheet)
        series_title = esc(self.config.series_title)
        original_url = esc(self.config.original_url)
        base_url = esc(self.config.base_url)
        header = Header(self.config).as_html(f"{self.config.series_title} of events in {self.year}")
        navigation = self.navigation_as_html()
        entries = self.entries_as_html()
        page = f"""<!DOCTYPE html>
{state}
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" type="text/css" href="{stylesheet}">
  <title>CEUR-WS.org - {series_title} of events in {year}</title>
</head>

<body>
{header}
{navigation}
  <p class="unobtrusive">Original: <a href="{original_url}/">{original_url}/</a> |
    Mirror: <a href="/">{base_url}/</a></p>

{entries}
</body>

</html>
"""
        return page

entries_as_html()

get the entries of the volumes, the highest volume number first

Returns:

Name Type Description
str str

the html divisions

Source code in ceursemantify/pages.py
931
932
933
934
935
936
937
938
939
940
941
def entries_as_html(self) -> str:
    """
    get the entries of the volumes, the highest volume number first

    Returns:
        str: the html divisions
    """
    by_number = {proceedings.volumeNumber: proceedings for proceedings in self.proceedings_list}
    numbers = sorted(by_number.keys(), reverse=True)
    entries = "\n".join(YearEntry(by_number[number], self.config).as_html() for number in numbers)
    return entries

navigation_as_html()

get the links to the previous and the next year, where those exist

Returns:

Name Type Description
str str

the html paragraph

Source code in ceursemantify/pages.py
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
def navigation_as_html(self) -> str:
    """
    get the links to the previous and the next year, where those exist

    Returns:
        str: the html paragraph
    """
    parts = []
    previous_year = self.year - 1
    next_year = self.year + 1
    if previous_year in self.years:
        parts.append(f"""<a href="../{previous_year}/" rel="prev">&laquo; {previous_year}</a>""")
    parts.append(str(self.year))
    if next_year in self.years:
        parts.append(f"""<a href="../{next_year}/" rel="next">{next_year} &raquo;</a>""")
    links = " | ".join(parts)
    navigation = f"""  <p>Year: {links}</p>"""
    return navigation

doi_as_html(doi)

get the DOI linked to its resolver, see issue 14

Parameters:

Name Type Description Default
doi Any

the DOI, None gives the empty string

required

Returns:

Name Type Description
str str

the html span, empty if there is no DOI

Source code in ceursemantify/pages.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def doi_as_html(doi: Any) -> str:
    """
    get the DOI linked to its resolver, see issue 14

    Args:
        doi: the DOI, None gives the empty string

    Returns:
        str: the html span, empty if there is no DOI
    """
    doi_html = ""
    if doi:
        doi_text = esc(doi)
        doi_html = f"""<span class="CEURDOI"><a href="https://doi.org/{doi_text}">{doi_text}</a></span>"""
    return doi_html

esc(value)

escape the given value for use in html text and attributes

Parameters:

Name Type Description Default
value Any

the value, None gives the empty string

required

Returns:

Name Type Description
str str

the escaped text

Source code in ceursemantify/pages.py
24
25
26
27
28
29
30
31
32
33
34
35
36
def esc(value: Any) -> str:
    """
    escape the given value for use in html text and attributes

    Args:
        value: the value, None gives the empty string

    Returns:
        str: the escaped text
    """
    text = "" if value is None else str(value)
    escaped = html.escape(text, quote=True)
    return escaped

register-dois

doiPrefix = config.get('doiPrefix', '') module-attribute

Possible return codes from DataCite REST API

200 DOI updated 201 DOI registered 422 This DOI has already been taken

see https://support.datacite.org/docs/api-error-codes

site

Created on 2026-10-01

@author: wf

CeurSpt

access to the JSON-LD of volumes as served by ceur-spt

Source code in ceursemantify/site.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
class CeurSpt:
    """
    access to the JSON-LD of volumes as served by ceur-spt
    """

    def __init__(self, spt_url: str, timeout: float = 60.0):
        """
        constructor

        Args:
            spt_url: the base url of the service, http, https or file
            timeout: the time in seconds to wait for an answer
        """
        self.spt_url = spt_url.rstrip("/")
        self.timeout = timeout
        # one session for all requests so that the connection is kept alive
        self.session = requests.Session()

    def volume_url(self, number: int) -> str:
        """
        get the url of the JSON-LD of the volume with the given number

        Args:
            number: the volume number

        Returns:
            str: the url
        """
        volume_url = f"{self.spt_url}/Vol-{number}.jsonld"
        return volume_url

    def volume_text(self, number: int) -> str:
        """
        fetch the text of the JSON-LD of the volume with the given number

        Args:
            number: the volume number

        Returns:
            str: the text

        Raises:
            OSError: if the service does not answer or answers with an error
        """
        volume_url = self.volume_url(number)
        if volume_url.startswith("file://"):
            with urllib.request.urlopen(volume_url, timeout=self.timeout) as response:
                text = response.read().decode("utf-8")
        else:
            response = self.session.get(volume_url, timeout=self.timeout)
            response.raise_for_status()
            text = response.text
        return text

    def volume_record(self, number: int) -> Dict[str, Any]:
        """
        fetch the JSON-LD of the volume with the given number

        Args:
            number: the volume number

        Returns:
            Dict[str, Any]: the JSON-LD document

        Raises:
            OSError: if the service does not answer or answers with an error
            ValueError: if the answer is not JSON
        """
        record = json.loads(self.volume_text(number))
        return record

__init__(spt_url, timeout=60.0)

constructor

Parameters:

Name Type Description Default
spt_url str

the base url of the service, http, https or file

required
timeout float

the time in seconds to wait for an answer

60.0
Source code in ceursemantify/site.py
73
74
75
76
77
78
79
80
81
82
83
84
def __init__(self, spt_url: str, timeout: float = 60.0):
    """
    constructor

    Args:
        spt_url: the base url of the service, http, https or file
        timeout: the time in seconds to wait for an answer
    """
    self.spt_url = spt_url.rstrip("/")
    self.timeout = timeout
    # one session for all requests so that the connection is kept alive
    self.session = requests.Session()

volume_record(number)

fetch the JSON-LD of the volume with the given number

Parameters:

Name Type Description Default
number int

the volume number

required

Returns:

Type Description
Dict[str, Any]

Dict[str, Any]: the JSON-LD document

Raises:

Type Description
OSError

if the service does not answer or answers with an error

ValueError

if the answer is not JSON

Source code in ceursemantify/site.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
def volume_record(self, number: int) -> Dict[str, Any]:
    """
    fetch the JSON-LD of the volume with the given number

    Args:
        number: the volume number

    Returns:
        Dict[str, Any]: the JSON-LD document

    Raises:
        OSError: if the service does not answer or answers with an error
        ValueError: if the answer is not JSON
    """
    record = json.loads(self.volume_text(number))
    return record

volume_text(number)

fetch the text of the JSON-LD of the volume with the given number

Parameters:

Name Type Description Default
number int

the volume number

required

Returns:

Name Type Description
str str

the text

Raises:

Type Description
OSError

if the service does not answer or answers with an error

Source code in ceursemantify/site.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def volume_text(self, number: int) -> str:
    """
    fetch the text of the JSON-LD of the volume with the given number

    Args:
        number: the volume number

    Returns:
        str: the text

    Raises:
        OSError: if the service does not answer or answers with an error
    """
    volume_url = self.volume_url(number)
    if volume_url.startswith("file://"):
        with urllib.request.urlopen(volume_url, timeout=self.timeout) as response:
            text = response.read().decode("utf-8")
    else:
        response = self.session.get(volume_url, timeout=self.timeout)
        response.raise_for_status()
        text = response.text
    return text

volume_url(number)

get the url of the JSON-LD of the volume with the given number

Parameters:

Name Type Description Default
number int

the volume number

required

Returns:

Name Type Description
str str

the url

Source code in ceursemantify/site.py
86
87
88
89
90
91
92
93
94
95
96
97
def volume_url(self, number: int) -> str:
    """
    get the url of the JSON-LD of the volume with the given number

    Args:
        number: the volume number

    Returns:
        str: the url
    """
    volume_url = f"{self.spt_url}/Vol-{number}.jsonld"
    return volume_url

Site

the directory tree of the generated pages: /index.html and /Vol-/index.html with index.jsonld

Source code in ceursemantify/site.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
class Site:
    """
    the directory tree of the generated pages:
    <year>/index.html and <year>/Vol-<number>/index.html with index.jsonld
    """

    def __init__(self, root: str, jsonld: JsonLd):
        """
        constructor

        Args:
            root: the directory that holds the year directories
            jsonld: the JSON-LD support of the research model
        """
        self.root = root
        self.jsonld = jsonld

    def year_dir(self, year: int) -> str:
        """
        get the directory of the given year

        Args:
            year: the year

        Returns:
            str: the path
        """
        year_dir = os.path.join(self.root, str(year))
        return year_dir

    def volume_dir(self, proceedings: Proceedings) -> str:
        """
        get the directory of the given proceedings

        Args:
            proceedings: the proceedings, with a year and a volume number

        Returns:
            str: the path
        """
        volume_dir = os.path.join(self.year_dir(proceedings.year()), f"Vol-{proceedings.volumeNumber}")
        return volume_dir

    def paper_dir(self, proceedings: Proceedings, paper_id: str) -> str:
        """
        get the directory of the paper with the given id

        Args:
            proceedings: the proceedings the paper is published in
            paper_id: the id of the paper e.g. paper1

        Returns:
            str: the path
        """
        paper_dir = os.path.join(self.volume_dir(proceedings), paper_id)
        return paper_dir

    def write(self, path: str, content: str, report: SiteReport) -> None:
        """
        write the given content to the given file

        Args:
            path: the path of the file
            content: the content
            report: the report to add the path to
        """
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "w", encoding="utf-8") as out_file:
            out_file.write(content)
        report.written.append(path)

    def jsonld_paths(self, year: Optional[int] = None) -> List[str]:
        """
        get the paths of the JSON-LD files of the volumes

        Args:
            year: the year to get the files for, all years if None

        Returns:
            List[str]: the sorted paths of the index.jsonld files
        """
        year_pattern = str(year) if year is not None else "[0-9][0-9][0-9][0-9]"
        pattern = os.path.join(self.root, year_pattern, "Vol-*", "index.jsonld")
        paths = sorted(glob.glob(pattern))
        return paths

    def volume_numbers(self, year: int) -> List[int]:
        """
        get the numbers of the volumes that are present for the given year

        Args:
            year: the year

        Returns:
            List[int]: the sorted volume numbers
        """
        names = [os.path.basename(os.path.dirname(path)) for path in self.jsonld_paths(year)]
        numbers = sorted(int(name.split("-", 1)[1]) for name in names)
        return numbers

    def read_proceedings(self, path: str) -> Proceedings:
        """
        read the proceedings of the given JSON-LD file

        Args:
            path: the path of an index.jsonld file

        Returns:
            Proceedings: the proceedings
        """
        with open(path, "r", encoding="utf-8") as jsonld_file:
            document = json.load(jsonld_file)
        proceedings = self.jsonld.proceedings(document)
        return proceedings

    def proceedings_of_year(self, year: int) -> List[Proceedings]:
        """
        get the proceedings that are present for the given year

        Args:
            year: the year

        Returns:
            List[Proceedings]: the proceedings
        """
        proceedings_list = [self.read_proceedings(path) for path in self.jsonld_paths(year)]
        return proceedings_list

    def years(self) -> List[int]:
        """
        get the years that have a directory

        Returns:
            List[int]: the sorted years
        """
        years = []
        if os.path.isdir(self.root):
            years = sorted(int(name) for name in os.listdir(self.root) if name.isdigit() and len(name) == 4)
        return years

__init__(root, jsonld)

constructor

Parameters:

Name Type Description Default
root str

the directory that holds the year directories

required
jsonld JsonLd

the JSON-LD support of the research model

required
Source code in ceursemantify/site.py
190
191
192
193
194
195
196
197
198
199
def __init__(self, root: str, jsonld: JsonLd):
    """
    constructor

    Args:
        root: the directory that holds the year directories
        jsonld: the JSON-LD support of the research model
    """
    self.root = root
    self.jsonld = jsonld

jsonld_paths(year=None)

get the paths of the JSON-LD files of the volumes

Parameters:

Name Type Description Default
year Optional[int]

the year to get the files for, all years if None

None

Returns:

Type Description
List[str]

List[str]: the sorted paths of the index.jsonld files

Source code in ceursemantify/site.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
def jsonld_paths(self, year: Optional[int] = None) -> List[str]:
    """
    get the paths of the JSON-LD files of the volumes

    Args:
        year: the year to get the files for, all years if None

    Returns:
        List[str]: the sorted paths of the index.jsonld files
    """
    year_pattern = str(year) if year is not None else "[0-9][0-9][0-9][0-9]"
    pattern = os.path.join(self.root, year_pattern, "Vol-*", "index.jsonld")
    paths = sorted(glob.glob(pattern))
    return paths

paper_dir(proceedings, paper_id)

get the directory of the paper with the given id

Parameters:

Name Type Description Default
proceedings Proceedings

the proceedings the paper is published in

required
paper_id str

the id of the paper e.g. paper1

required

Returns:

Name Type Description
str str

the path

Source code in ceursemantify/site.py
227
228
229
230
231
232
233
234
235
236
237
238
239
def paper_dir(self, proceedings: Proceedings, paper_id: str) -> str:
    """
    get the directory of the paper with the given id

    Args:
        proceedings: the proceedings the paper is published in
        paper_id: the id of the paper e.g. paper1

    Returns:
        str: the path
    """
    paper_dir = os.path.join(self.volume_dir(proceedings), paper_id)
    return paper_dir

proceedings_of_year(year)

get the proceedings that are present for the given year

Parameters:

Name Type Description Default
year int

the year

required

Returns:

Type Description
List[Proceedings]

List[Proceedings]: the proceedings

Source code in ceursemantify/site.py
299
300
301
302
303
304
305
306
307
308
309
310
def proceedings_of_year(self, year: int) -> List[Proceedings]:
    """
    get the proceedings that are present for the given year

    Args:
        year: the year

    Returns:
        List[Proceedings]: the proceedings
    """
    proceedings_list = [self.read_proceedings(path) for path in self.jsonld_paths(year)]
    return proceedings_list

read_proceedings(path)

read the proceedings of the given JSON-LD file

Parameters:

Name Type Description Default
path str

the path of an index.jsonld file

required

Returns:

Name Type Description
Proceedings Proceedings

the proceedings

Source code in ceursemantify/site.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
def read_proceedings(self, path: str) -> Proceedings:
    """
    read the proceedings of the given JSON-LD file

    Args:
        path: the path of an index.jsonld file

    Returns:
        Proceedings: the proceedings
    """
    with open(path, "r", encoding="utf-8") as jsonld_file:
        document = json.load(jsonld_file)
    proceedings = self.jsonld.proceedings(document)
    return proceedings

volume_dir(proceedings)

get the directory of the given proceedings

Parameters:

Name Type Description Default
proceedings Proceedings

the proceedings, with a year and a volume number

required

Returns:

Name Type Description
str str

the path

Source code in ceursemantify/site.py
214
215
216
217
218
219
220
221
222
223
224
225
def volume_dir(self, proceedings: Proceedings) -> str:
    """
    get the directory of the given proceedings

    Args:
        proceedings: the proceedings, with a year and a volume number

    Returns:
        str: the path
    """
    volume_dir = os.path.join(self.year_dir(proceedings.year()), f"Vol-{proceedings.volumeNumber}")
    return volume_dir

volume_numbers(year)

get the numbers of the volumes that are present for the given year

Parameters:

Name Type Description Default
year int

the year

required

Returns:

Type Description
List[int]

List[int]: the sorted volume numbers

Source code in ceursemantify/site.py
270
271
272
273
274
275
276
277
278
279
280
281
282
def volume_numbers(self, year: int) -> List[int]:
    """
    get the numbers of the volumes that are present for the given year

    Args:
        year: the year

    Returns:
        List[int]: the sorted volume numbers
    """
    names = [os.path.basename(os.path.dirname(path)) for path in self.jsonld_paths(year)]
    numbers = sorted(int(name.split("-", 1)[1]) for name in names)
    return numbers

write(path, content, report)

write the given content to the given file

Parameters:

Name Type Description Default
path str

the path of the file

required
content str

the content

required
report SiteReport

the report to add the path to

required
Source code in ceursemantify/site.py
241
242
243
244
245
246
247
248
249
250
251
252
253
def write(self, path: str, content: str, report: SiteReport) -> None:
    """
    write the given content to the given file

    Args:
        path: the path of the file
        content: the content
        report: the report to add the path to
    """
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w", encoding="utf-8") as out_file:
        out_file.write(content)
    report.written.append(path)

year_dir(year)

get the directory of the given year

Parameters:

Name Type Description Default
year int

the year

required

Returns:

Name Type Description
str str

the path

Source code in ceursemantify/site.py
201
202
203
204
205
206
207
208
209
210
211
212
def year_dir(self, year: int) -> str:
    """
    get the directory of the given year

    Args:
        year: the year

    Returns:
        str: the path
    """
    year_dir = os.path.join(self.root, str(year))
    return year_dir

years()

get the years that have a directory

Returns:

Type Description
List[int]

List[int]: the sorted years

Source code in ceursemantify/site.py
312
313
314
315
316
317
318
319
320
321
322
def years(self) -> List[int]:
    """
    get the years that have a directory

    Returns:
        List[int]: the sorted years
    """
    years = []
    if os.path.isdir(self.root):
        years = sorted(int(name) for name in os.listdir(self.root) if name.isdigit() and len(name) == 4)
    return years

SiteConfig

configuration of the generated site, see resources/site.yaml

Source code in ceursemantify/site.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@lod_storable
class SiteConfig:
    """
    configuration of the generated site, see resources/site.yaml
    """

    base_url: str = ""
    original_url: str = ""
    spt_url: str = ""
    stylesheet: str = ""
    header: str = ""
    series_title: str = ""
    series_issn: str = ""
    validator_url: str = ""

    @classmethod
    def default_path(cls) -> str:
        """
        get the path of the default site configuration

        Returns:
            str: the path of resources/site.yaml
        """
        path = os.path.join(os.path.dirname(__file__), "resources", "site.yaml")
        return path

    @classmethod
    def of_yaml(cls, path: Optional[str] = None) -> "SiteConfig":
        """
        load the site configuration

        Args:
            path: the path of the yaml file, the default configuration if None

        Returns:
            SiteConfig: the configuration, with the path of the header
                resolved against the directory of the yaml file
        """
        if path is None:
            path = cls.default_path()
        config = cls.load_from_yaml_file(path)
        if config.header and not os.path.isabs(config.header):
            config_dir = os.path.dirname(os.path.abspath(path))
            config.header = os.path.join(config_dir, config.header)
        return config

default_path() classmethod

get the path of the default site configuration

Returns:

Name Type Description
str str

the path of resources/site.yaml

Source code in ceursemantify/site.py
36
37
38
39
40
41
42
43
44
45
@classmethod
def default_path(cls) -> str:
    """
    get the path of the default site configuration

    Returns:
        str: the path of resources/site.yaml
    """
    path = os.path.join(os.path.dirname(__file__), "resources", "site.yaml")
    return path

of_yaml(path=None) classmethod

load the site configuration

Parameters:

Name Type Description Default
path Optional[str]

the path of the yaml file, the default configuration if None

None

Returns:

Name Type Description
SiteConfig SiteConfig

the configuration, with the path of the header resolved against the directory of the yaml file

Source code in ceursemantify/site.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@classmethod
def of_yaml(cls, path: Optional[str] = None) -> "SiteConfig":
    """
    load the site configuration

    Args:
        path: the path of the yaml file, the default configuration if None

    Returns:
        SiteConfig: the configuration, with the path of the header
            resolved against the directory of the yaml file
    """
    if path is None:
        path = cls.default_path()
    config = cls.load_from_yaml_file(path)
    if config.header and not os.path.isabs(config.header):
        config_dir = os.path.dirname(os.path.abspath(path))
        config.header = os.path.join(config_dir, config.header)
    return config

SiteReport

what a run wrote and what it could not handle

Source code in ceursemantify/site.py
173
174
175
176
177
178
179
180
181
@lod_storable
class SiteReport:
    """
    what a run wrote and what it could not handle
    """

    written: List[str] = field(default_factory=list)
    problems: List[str] = field(default_factory=list)
    seconds: float = 0.0

VolumeRanges

volume numbers given as numbers and ranges e.g. 3889 3887-3912

Source code in ceursemantify/site.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
class VolumeRanges:
    """
    volume numbers given as numbers and ranges e.g. 3889 3887-3912
    """

    @classmethod
    def numbers(cls, ranges: List[str]) -> List[int]:
        """
        get the volume numbers of the given numbers and ranges

        Args:
            ranges: numbers such as 3889 and ranges such as 3887-3912

        Returns:
            List[int]: the sorted volume numbers without duplicates

        Raises:
            ValueError: if a range is not a number or two numbers separated by a hyphen
        """
        numbers = set()
        for volume_range in ranges:
            parts = volume_range.split("-")
            if len(parts) > 2 or not all(part.isdigit() for part in parts):
                raise ValueError(f"not a volume number or range: {volume_range}")
            first = int(parts[0])
            last = int(parts[-1])
            if last < first:
                raise ValueError(f"range ends before it starts: {volume_range}")
            numbers.update(range(first, last + 1))
        sorted_numbers = sorted(numbers)
        return sorted_numbers

numbers(ranges) classmethod

get the volume numbers of the given numbers and ranges

Parameters:

Name Type Description Default
ranges List[str]

numbers such as 3889 and ranges such as 3887-3912

required

Returns:

Type Description
List[int]

List[int]: the sorted volume numbers without duplicates

Raises:

Type Description
ValueError

if a range is not a number or two numbers separated by a hyphen

Source code in ceursemantify/site.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
@classmethod
def numbers(cls, ranges: List[str]) -> List[int]:
    """
    get the volume numbers of the given numbers and ranges

    Args:
        ranges: numbers such as 3889 and ranges such as 3887-3912

    Returns:
        List[int]: the sorted volume numbers without duplicates

    Raises:
        ValueError: if a range is not a number or two numbers separated by a hyphen
    """
    numbers = set()
    for volume_range in ranges:
        parts = volume_range.split("-")
        if len(parts) > 2 or not all(part.isdigit() for part in parts):
            raise ValueError(f"not a volume number or range: {volume_range}")
        first = int(parts[0])
        last = int(parts[-1])
        if last < first:
            raise ValueError(f"range ends before it starts: {volume_range}")
        numbers.update(range(first, last + 1))
    sorted_numbers = sorted(numbers)
    return sorted_numbers

volume_html

Created on 2026-10-13

Input mapping of a volume page as submitted by the proceedings chair (https://ceur-ws.org/Vol-XXX/index.html template) to the research model, see https://github.com/ceurws/semantify/issues/28

@author: wf

VolumeHtml

a volume page with the CEUR* marks of the volume template

Source code in ceursemantify/volume_html.py
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
class VolumeHtml:
    """
    a volume page with the CEUR* marks of the volume template
    """

    publisher_name = "CEUR-WS.org"
    # no volume was published before, the plausible range of CEURPUBDATE starts here, see issue 58
    first_pub_date = "1995-01-01"
    # a year, an ordinal or the YYYY placeholder of the Vol-XXX template, see issue 22
    edition_pattern = re.compile(r"\b(?:(?:19|20)\d\d|\d+(?:st|nd|rd|th)|YYYY)\b")
    # tried after the declared charset; latin-1 decodes every byte
    fallback_encodings = ["utf-8", "cp1252", "latin-1"]
    # an embedded or generated page announces its state and carries its JSON-LD in a fence with a
    # marker line, see issues 28 and 49
    embedded_pattern = re.compile(r"<!-- CEURSTATE=(embedded|generated)[^\n]*-->")
    fence_pattern = re.compile(r"```json-ld\n(?:[^\n{\[]*\n)?(.*?)\n```", re.DOTALL)
    _jsonld: Optional[JsonLd] = None

    def __init__(self, text: str, base_url: str = "https://ceur-ws.org", volume_number: Optional[int] = None):
        """
        constructor

        Args:
            text: the html of the volume page
            base_url: the url of the site the volume is published on
            volume_number: the number of the volume for pages without CEURVOLNR
        """
        self.text = text
        self.base_url = base_url.rstrip("/")
        self.volume_number = volume_number
        self.soup = BeautifulSoup(text, "html.parser")
        self.issues: List[MappingIssue] = []
        self.embedded = False
        self.jsonld_document: Optional[dict] = None

    @classmethod
    def jsonld(cls) -> JsonLd:
        """
        the JSON-LD support of the research model, loaded once
        """
        if cls._jsonld is None:
            cls._jsonld = JsonLd.of_yaml()
        return cls._jsonld

    def read_embedded(self, path: str) -> None:
        """
        read the JSON-LD record of a page, the source of the mapping instead of the markup, see issue 28:
        index.jsonld beside the page is the record that the DOI registration writes, for every page
        whatever its state, see issues 42 and 49; the fence of an embedded or generated page is the
        copy and is read only when there is no index.jsonld

        Args:
            path: the path of the index.html
        """
        document = None
        jsonld_path = os.path.join(os.path.dirname(path), "index.jsonld")
        marked = self.embedded_pattern.search(self.text) is not None
        if os.path.isfile(jsonld_path):
            with open(jsonld_path, "r", encoding="utf-8") as jsonld_file:
                document = json.load(jsonld_file)
        elif marked:
            match = self.fence_pattern.search(self.text)
            if match and match.group(1).strip():
                document = json.loads(match.group(1))
        if document is None and not marked:
            return
        self.embedded = True
        if document is None:
            self.issues.append(
                MappingIssue(
                    "Proceedings", "CEURSTATE", "embedded, but neither the fence nor index.jsonld holds the JSON-LD"
                )
            )
        self.jsonld_document = document

    @classmethod
    def decode(cls, raw: bytes) -> Tuple[str, Optional[str], str]:
        """
        decode the bytes of a volume page with its declared charset,
        then with the fallback encodings, see https://github.com/ceurws/semantify/issues/38

        Args:
            raw: the bytes of the page

        Returns:
            Tuple[str, Optional[str], str]: the text, the declared charset or None and the encoding used
        """
        match = re.search(rb"charset\s*=\s*[\"']?([A-Za-z0-9_.:\-]+)", raw[:4096], re.IGNORECASE)
        declared = match.group(1).decode("ascii") if match else None
        encodings = ([declared] if declared else []) + cls.fallback_encodings
        text = None
        used = None
        for encoding in encodings:
            try:
                text = raw.decode(encoding)
                used = encoding
                break
            except (LookupError, UnicodeDecodeError):
                pass
        return text, declared, used

    @classmethod
    def of_file(cls, path: str, base_url: str = "https://ceur-ws.org") -> "VolumeHtml":
        """
        read the volume page from the given file

        Args:
            path: the path of the index.html, the volume number is taken from a Vol-N part of it
            base_url: the url of the site the volume is published on

        Returns:
            VolumeHtml: the volume page, an EarlyVolumeHtml for a page without CEURVOLNR
        """
        with open(path, "rb") as html_file:
            raw = html_file.read()
        text, declared, used = cls.decode(raw)
        numbers = re.findall(r"Vol-(\d+)", path)
        volume_number = int(numbers[-1]) if numbers else None
        volume_class = cls
        if "CEURVOLNR" not in text:
            # imported here since the early volume page is a subclass of this one
            from ceursemantify.early_volume_html import EarlyVolumeHtml

            volume_class = EarlyVolumeHtml
        volume_html = volume_class(text, base_url, volume_number)
        volume_html.read_embedded(path)
        expected = (declared or "utf-8").lower().replace("-", "")
        if used.lower().replace("-", "") != expected:
            volume_html.issues.append(MappingIssue("Proceedings", "charset", f"declared {declared}, decoded as {used}"))
        return volume_html

    def mark(self, css_class: str, root: Optional[Tag] = None) -> Optional[Tag]:
        """
        get the first element with the given CEUR mark

        Args:
            css_class: the mark e.g. CEURVOLNR
            root: the element to search in, the whole page if None

        Returns:
            Optional[Tag]: the element, None if there is none
        """
        root = root if root is not None else self.soup
        element = root.find(class_=css_class)
        return element

    def mark_text(self, css_class: str, root: Optional[Tag] = None) -> Optional[str]:
        """
        get the text of the first element with the given CEUR mark

        Args:
            css_class: the mark e.g. CEURVOLNR
            root: the element to search in, the whole page if None

        Returns:
            Optional[str]: the single line text, None if there is no element or no text
        """
        text = None
        element = self.mark(css_class, root)
        if element is not None:
            text = Convert.text(element.get_text()) or None
        return text

    def link_of(self, element: Optional[Tag]) -> Optional[str]:
        """
        get the href of the link the given element is or is wrapped in

        Args:
            element: the element

        Returns:
            Optional[str]: the href, None if the element is not linked
        """
        href = None
        if element is not None:
            link = element if element.name == "a" else element.find_parent("a")
            if link is not None:
                href = link.get("href")
        return href

    def required(self, css_class: str, result: MappingResult) -> Optional[str]:
        """
        get the text of a mark that every volume page must have

        Args:
            css_class: the mark
            result: the mapping result to report a missing mark to

        Returns:
            Optional[str]: the text, None if the mark is missing
        """
        text = self.mark_text(css_class)
        if text is None:
            result.issues.append(MappingIssue("Proceedings", css_class, "mark missing"))
        return text

    def event(self, result: MappingResult) -> Event:
        """
        get the event of the volume

        Args:
            result: the mapping result to report issues to

        Returns:
            Event: the event
        """
        acronym_element = self.mark("CEURVOLACRONYM")
        event = Event(
            acronym=self.mark_text("CEURVOLACRONYM"),
            title=self.mark_text("CEURVOLTITLE"),
            homepage=self.link_of(acronym_element),
        )
        colocated = self.mark_text("CEURCOLOCATED")
        # NONE is the template's value for a volume that is not co-located, see issue 55
        if colocated and colocated.strip().upper() != "NONE":
            event.colocated_with = Event(acronym=colocated)
        loctime_text = self.required("CEURLOCTIME", result)
        if loctime_text:
            loctime = LocTime.parse(loctime_text)
            # the full text as ceur-spt has it; place, city and country are derived
            event.location = loctime.text
            event.startDate = loctime.startDate
            event.endDate = loctime.endDate
            if loctime.virtual:
                event.virtual = True
            if loctime.city:
                event.city = City(name=loctime.city)
            if loctime.country:
                event.country = Country(name=loctime.country)
            if loctime.message:
                result.issues.append(MappingIssue("Event", "CEURLOCTIME", loctime.message))
        return event

    def scholar(self, element: Tag) -> Scholar:
        """
        get the scholar of the given CEURVOLEDITOR or CEURAUTHOR element

        Args:
            element: the element with the name, optionally linked to an ORCID

        Returns:
            Scholar: the scholar
        """
        scholar = Scholar.of_full_name(element.get_text())
        for url in [element.get("itemid"), self.link_of(element)]:
            if url and "orcid.org/" in url:
                scholar.orcid = url.rstrip("/").rsplit("/", 1)[1]
                break
        return scholar

    def editors(self) -> List[Scholar]:
        """
        get the editors of the volume

        Returns:
            List[Scholar]: the editors in the order of the page
        """
        editors = [self.scholar(element) for element in self.soup.find_all(class_="CEURVOLEDITOR")]
        return editors

    def paper(self, item: Tag, url: str, result: MappingResult) -> Optional[Paper]:
        """
        get the paper of the given table of contents item

        Args:
            item: the li element of the paper, the id of the paper is the li id
                or the stem of the pdf link when the li has no id
                see https://github.com/ceurws/semantify/issues/36
            url: the url of the volume
            result: the mapping result to report issues to

        Returns:
            Optional[Paper]: the paper, None if the item has no link to a pdf,
                has no id and links to a pdf outside the volume,
                or links to a pdf outside the volume directory: a paper published elsewhere,
                see https://github.com/ceurws/semantify/issues/35
        """
        paper = None
        link = item.find("a", href=lambda href: href and href.lower().endswith(".pdf"))
        paper_id = item.get("id")
        if link is not None and not paper_id:
            href = link["href"]
            # without li id only a pdf of the volume itself is taken
            if not urlparse(href).scheme and "/" not in href.strip("./"):
                paper_id = posixpath.splitext(posixpath.basename(href))[0]
        if link is not None and paper_id:
            pdf_url = urljoin(url, link["href"])
            if not self.in_volume(pdf_url, url):
                result.issues.append(MappingIssue("Paper", paper_id, f"published elsewhere: {pdf_url}"))
            else:
                title = self.mark_text("CEURTITLE", item) or Convert.text(link.get_text()) or None
                if title is None:
                    result.issues.append(MappingIssue("Paper", paper_id, "no title"))
                paper = Paper(
                    id=paper_id,
                    title=title,
                    pdfUrl=pdf_url,
                    publishedIn=url,
                    authors=[self.scholar(element) for element in item.find_all(class_="CEURAUTHOR")],
                )
                paper.publish_in(url)
                pages = self.mark_text("CEURPAGES", item)
                if pages:
                    paper.pageStart = Convert.page_start(pages)
                    paper.pageEnd = Convert.page_end(pages)
                # the paper belongs to the CEURSESSION heading before it, see issue 54
                session = item.find_previous(class_="CEURSESSION")
                if session is not None:
                    paper.session = Convert.text(session.get_text()) or None
        return paper

    @classmethod
    def in_volume(cls, pdf_url: str, url: str) -> bool:
        """
        check whether the given pdf lies in the directory of the volume,
        the criterion for a paper of the volume, see https://github.com/ceurws/semantify/issues/35;
        the scheme is ignored so that http links to an https site still count

        Args:
            pdf_url: the absolute url of the pdf
            url: the url of the volume

        Returns:
            bool: True if the pdf is in the volume directory or one of its subdirectories
        """
        pdf_parts = urlparse(pdf_url)
        volume_parts = urlparse(url)
        in_volume = pdf_parts.netloc == volume_parts.netloc and pdf_parts.path.startswith(volume_parts.path)
        return in_volume

    def papers(self, url: str, result: MappingResult) -> List[Paper]:
        """
        get the papers of the volume

        Args:
            url: the url of the volume
            result: the mapping result to report issues to

        Returns:
            List[Paper]: the papers in the order of the table of contents
        """
        toc = self.mark("CEURTOC") or self.soup
        papers = []
        pdf_urls = set()
        for item in toc.find_all("li"):
            paper = self.paper(item, url, result)
            # an li around a nested list finds the pdf of its first child again
            if paper is not None and paper.pdfUrl not in pdf_urls:
                pdf_urls.add(paper.pdfUrl)
                papers.append(paper)
        return papers

    def volume_number_of(self, result: MappingResult) -> Optional[int]:
        """
        get the volume number of the CEURVOLNR mark

        Args:
            result: the mapping result to report a missing or unparsed mark to

        Returns:
            Optional[int]: the volume number, None if the mark is missing or unparsed
        """
        volume_number = None
        volume_mark = self.required("CEURVOLNR", result)
        if volume_mark:
            try:
                volume_number = Convert.volume_number(volume_mark)
            except ValueError as error:
                result.issues.append(MappingIssue("Proceedings", "CEURVOLNR", str(error)))
        return volume_number

    def proceedings(self) -> MappingResult:
        """
        map the volume page to the proceedings of the research model

        Returns:
            MappingResult: the proceedings and the issues
        """
        result = MappingResult()
        result.issues.extend(self.issues)
        if self.jsonld_document is not None:
            result.target = self.jsonld().proceedings(self.jsonld_document)
            return result
        proceedings = Proceedings(publisher=Publisher(name=self.publisher_name))
        proceedings.volumeNumber = self.volume_number_of(result)
        if proceedings.volumeNumber is not None:
            proceedings.url = f"{self.base_url}/Vol-{proceedings.volumeNumber}/"
            proceedings.archive = f"{self.base_url}/ftp-dir/Vol-{proceedings.volumeNumber}.zip"
        proceedings.urn = self.required("CEURURN", result)
        proceedings.title = self.required("CEURFULLTITLE", result) or self.mark_text("CEURVOLTITLE")
        # the title names the edition by year or ordinal, see issue 22
        if proceedings.title and not self.edition_pattern.search(proceedings.title):
            result.issues.append(
                MappingIssue("Proceedings", "CEURFULLTITLE", f"neither year nor ordinal in {proceedings.title}")
            )
        pub_date = self.required("CEURPUBDATE", result)
        if pub_date:
            try:
                proceedings.pubDate = Convert.iso_date(pub_date)
                # the plausible range of a publication date, see issue 58
                today = date.today().isoformat()
                if not self.first_pub_date <= proceedings.pubDate <= today:
                    result.issues.append(
                        MappingIssue(
                            "Proceedings",
                            "CEURPUBDATE",
                            f"{proceedings.pubDate} is not between {self.first_pub_date} and {today}",
                        )
                    )
            except ValueError as error:
                result.issues.append(MappingIssue("Proceedings", "CEURPUBDATE", str(error)))
        pub_year = self.mark_text("CEURPUBYEAR")
        if pub_year:
            try:
                proceedings.pubYear = Convert.integer(pub_year)
            except ValueError as error:
                result.issues.append(MappingIssue("Proceedings", "CEURPUBYEAR", str(error)))
        # the numbers of the summary sentence of the Vol-XXX template, see issue 54
        for css_class, field_name in [
            ("CEURSUBMITTEDPAPERS", "submittedPapers"),
            ("CEURACCEPTEDPAPERS", "acceptedPapers"),
        ]:
            count = self.mark_text(css_class)
            if count:
                try:
                    setattr(proceedings, field_name, Convert.integer(count))
                except ValueError as error:
                    result.issues.append(MappingIssue("Proceedings", css_class, str(error)))
        license_element = self.mark("CEURLIC")
        if license_element is not None:
            proceedings.license = self.link_of(license_element) or self.mark_text("CEURLIC")
        proceedings.event = self.event(result)
        proceedings.editors = self.editors()
        if proceedings.url:
            proceedings.papers = self.papers(proceedings.url, result)
        result.target = proceedings
        return result

__init__(text, base_url='https://ceur-ws.org', volume_number=None)

constructor

Parameters:

Name Type Description Default
text str

the html of the volume page

required
base_url str

the url of the site the volume is published on

'https://ceur-ws.org'
volume_number Optional[int]

the number of the volume for pages without CEURVOLNR

None
Source code in ceursemantify/volume_html.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, text: str, base_url: str = "https://ceur-ws.org", volume_number: Optional[int] = None):
    """
    constructor

    Args:
        text: the html of the volume page
        base_url: the url of the site the volume is published on
        volume_number: the number of the volume for pages without CEURVOLNR
    """
    self.text = text
    self.base_url = base_url.rstrip("/")
    self.volume_number = volume_number
    self.soup = BeautifulSoup(text, "html.parser")
    self.issues: List[MappingIssue] = []
    self.embedded = False
    self.jsonld_document: Optional[dict] = None

decode(raw) classmethod

decode the bytes of a volume page with its declared charset, then with the fallback encodings, see https://github.com/ceurws/semantify/issues/38

Parameters:

Name Type Description Default
raw bytes

the bytes of the page

required

Returns:

Type Description
Tuple[str, Optional[str], str]

Tuple[str, Optional[str], str]: the text, the declared charset or None and the encoding used

Source code in ceursemantify/volume_html.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
@classmethod
def decode(cls, raw: bytes) -> Tuple[str, Optional[str], str]:
    """
    decode the bytes of a volume page with its declared charset,
    then with the fallback encodings, see https://github.com/ceurws/semantify/issues/38

    Args:
        raw: the bytes of the page

    Returns:
        Tuple[str, Optional[str], str]: the text, the declared charset or None and the encoding used
    """
    match = re.search(rb"charset\s*=\s*[\"']?([A-Za-z0-9_.:\-]+)", raw[:4096], re.IGNORECASE)
    declared = match.group(1).decode("ascii") if match else None
    encodings = ([declared] if declared else []) + cls.fallback_encodings
    text = None
    used = None
    for encoding in encodings:
        try:
            text = raw.decode(encoding)
            used = encoding
            break
        except (LookupError, UnicodeDecodeError):
            pass
    return text, declared, used

editors()

get the editors of the volume

Returns:

Type Description
List[Scholar]

List[Scholar]: the editors in the order of the page

Source code in ceursemantify/volume_html.py
278
279
280
281
282
283
284
285
286
def editors(self) -> List[Scholar]:
    """
    get the editors of the volume

    Returns:
        List[Scholar]: the editors in the order of the page
    """
    editors = [self.scholar(element) for element in self.soup.find_all(class_="CEURVOLEDITOR")]
    return editors

event(result)

get the event of the volume

Parameters:

Name Type Description Default
result MappingResult

the mapping result to report issues to

required

Returns:

Name Type Description
Event Event

the event

Source code in ceursemantify/volume_html.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def event(self, result: MappingResult) -> Event:
    """
    get the event of the volume

    Args:
        result: the mapping result to report issues to

    Returns:
        Event: the event
    """
    acronym_element = self.mark("CEURVOLACRONYM")
    event = Event(
        acronym=self.mark_text("CEURVOLACRONYM"),
        title=self.mark_text("CEURVOLTITLE"),
        homepage=self.link_of(acronym_element),
    )
    colocated = self.mark_text("CEURCOLOCATED")
    # NONE is the template's value for a volume that is not co-located, see issue 55
    if colocated and colocated.strip().upper() != "NONE":
        event.colocated_with = Event(acronym=colocated)
    loctime_text = self.required("CEURLOCTIME", result)
    if loctime_text:
        loctime = LocTime.parse(loctime_text)
        # the full text as ceur-spt has it; place, city and country are derived
        event.location = loctime.text
        event.startDate = loctime.startDate
        event.endDate = loctime.endDate
        if loctime.virtual:
            event.virtual = True
        if loctime.city:
            event.city = City(name=loctime.city)
        if loctime.country:
            event.country = Country(name=loctime.country)
        if loctime.message:
            result.issues.append(MappingIssue("Event", "CEURLOCTIME", loctime.message))
    return event

in_volume(pdf_url, url) classmethod

check whether the given pdf lies in the directory of the volume, the criterion for a paper of the volume, see https://github.com/ceurws/semantify/issues/35; the scheme is ignored so that http links to an https site still count

Parameters:

Name Type Description Default
pdf_url str

the absolute url of the pdf

required
url str

the url of the volume

required

Returns:

Name Type Description
bool bool

True if the pdf is in the volume directory or one of its subdirectories

Source code in ceursemantify/volume_html.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
@classmethod
def in_volume(cls, pdf_url: str, url: str) -> bool:
    """
    check whether the given pdf lies in the directory of the volume,
    the criterion for a paper of the volume, see https://github.com/ceurws/semantify/issues/35;
    the scheme is ignored so that http links to an https site still count

    Args:
        pdf_url: the absolute url of the pdf
        url: the url of the volume

    Returns:
        bool: True if the pdf is in the volume directory or one of its subdirectories
    """
    pdf_parts = urlparse(pdf_url)
    volume_parts = urlparse(url)
    in_volume = pdf_parts.netloc == volume_parts.netloc and pdf_parts.path.startswith(volume_parts.path)
    return in_volume

jsonld() classmethod

the JSON-LD support of the research model, loaded once

Source code in ceursemantify/volume_html.py
63
64
65
66
67
68
69
70
@classmethod
def jsonld(cls) -> JsonLd:
    """
    the JSON-LD support of the research model, loaded once
    """
    if cls._jsonld is None:
        cls._jsonld = JsonLd.of_yaml()
    return cls._jsonld

get the href of the link the given element is or is wrapped in

Parameters:

Name Type Description Default
element Optional[Tag]

the element

required

Returns:

Type Description
Optional[str]

Optional[str]: the href, None if the element is not linked

Source code in ceursemantify/volume_html.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def link_of(self, element: Optional[Tag]) -> Optional[str]:
    """
    get the href of the link the given element is or is wrapped in

    Args:
        element: the element

    Returns:
        Optional[str]: the href, None if the element is not linked
    """
    href = None
    if element is not None:
        link = element if element.name == "a" else element.find_parent("a")
        if link is not None:
            href = link.get("href")
    return href

mark(css_class, root=None)

get the first element with the given CEUR mark

Parameters:

Name Type Description Default
css_class str

the mark e.g. CEURVOLNR

required
root Optional[Tag]

the element to search in, the whole page if None

None

Returns:

Type Description
Optional[Tag]

Optional[Tag]: the element, None if there is none

Source code in ceursemantify/volume_html.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def mark(self, css_class: str, root: Optional[Tag] = None) -> Optional[Tag]:
    """
    get the first element with the given CEUR mark

    Args:
        css_class: the mark e.g. CEURVOLNR
        root: the element to search in, the whole page if None

    Returns:
        Optional[Tag]: the element, None if there is none
    """
    root = root if root is not None else self.soup
    element = root.find(class_=css_class)
    return element

mark_text(css_class, root=None)

get the text of the first element with the given CEUR mark

Parameters:

Name Type Description Default
css_class str

the mark e.g. CEURVOLNR

required
root Optional[Tag]

the element to search in, the whole page if None

None

Returns:

Type Description
Optional[str]

Optional[str]: the single line text, None if there is no element or no text

Source code in ceursemantify/volume_html.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def mark_text(self, css_class: str, root: Optional[Tag] = None) -> Optional[str]:
    """
    get the text of the first element with the given CEUR mark

    Args:
        css_class: the mark e.g. CEURVOLNR
        root: the element to search in, the whole page if None

    Returns:
        Optional[str]: the single line text, None if there is no element or no text
    """
    text = None
    element = self.mark(css_class, root)
    if element is not None:
        text = Convert.text(element.get_text()) or None
    return text

of_file(path, base_url='https://ceur-ws.org') classmethod

read the volume page from the given file

Parameters:

Name Type Description Default
path str

the path of the index.html, the volume number is taken from a Vol-N part of it

required
base_url str

the url of the site the volume is published on

'https://ceur-ws.org'

Returns:

Name Type Description
VolumeHtml VolumeHtml

the volume page, an EarlyVolumeHtml for a page without CEURVOLNR

Source code in ceursemantify/volume_html.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
@classmethod
def of_file(cls, path: str, base_url: str = "https://ceur-ws.org") -> "VolumeHtml":
    """
    read the volume page from the given file

    Args:
        path: the path of the index.html, the volume number is taken from a Vol-N part of it
        base_url: the url of the site the volume is published on

    Returns:
        VolumeHtml: the volume page, an EarlyVolumeHtml for a page without CEURVOLNR
    """
    with open(path, "rb") as html_file:
        raw = html_file.read()
    text, declared, used = cls.decode(raw)
    numbers = re.findall(r"Vol-(\d+)", path)
    volume_number = int(numbers[-1]) if numbers else None
    volume_class = cls
    if "CEURVOLNR" not in text:
        # imported here since the early volume page is a subclass of this one
        from ceursemantify.early_volume_html import EarlyVolumeHtml

        volume_class = EarlyVolumeHtml
    volume_html = volume_class(text, base_url, volume_number)
    volume_html.read_embedded(path)
    expected = (declared or "utf-8").lower().replace("-", "")
    if used.lower().replace("-", "") != expected:
        volume_html.issues.append(MappingIssue("Proceedings", "charset", f"declared {declared}, decoded as {used}"))
    return volume_html

paper(item, url, result)

get the paper of the given table of contents item

Parameters:

Name Type Description Default
item Tag

the li element of the paper, the id of the paper is the li id or the stem of the pdf link when the li has no id see https://github.com/ceurws/semantify/issues/36

required
url str

the url of the volume

required
result MappingResult

the mapping result to report issues to

required

Returns:

Type Description
Optional[Paper]

Optional[Paper]: the paper, None if the item has no link to a pdf, has no id and links to a pdf outside the volume, or links to a pdf outside the volume directory: a paper published elsewhere, see https://github.com/ceurws/semantify/issues/35

Source code in ceursemantify/volume_html.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
def paper(self, item: Tag, url: str, result: MappingResult) -> Optional[Paper]:
    """
    get the paper of the given table of contents item

    Args:
        item: the li element of the paper, the id of the paper is the li id
            or the stem of the pdf link when the li has no id
            see https://github.com/ceurws/semantify/issues/36
        url: the url of the volume
        result: the mapping result to report issues to

    Returns:
        Optional[Paper]: the paper, None if the item has no link to a pdf,
            has no id and links to a pdf outside the volume,
            or links to a pdf outside the volume directory: a paper published elsewhere,
            see https://github.com/ceurws/semantify/issues/35
    """
    paper = None
    link = item.find("a", href=lambda href: href and href.lower().endswith(".pdf"))
    paper_id = item.get("id")
    if link is not None and not paper_id:
        href = link["href"]
        # without li id only a pdf of the volume itself is taken
        if not urlparse(href).scheme and "/" not in href.strip("./"):
            paper_id = posixpath.splitext(posixpath.basename(href))[0]
    if link is not None and paper_id:
        pdf_url = urljoin(url, link["href"])
        if not self.in_volume(pdf_url, url):
            result.issues.append(MappingIssue("Paper", paper_id, f"published elsewhere: {pdf_url}"))
        else:
            title = self.mark_text("CEURTITLE", item) or Convert.text(link.get_text()) or None
            if title is None:
                result.issues.append(MappingIssue("Paper", paper_id, "no title"))
            paper = Paper(
                id=paper_id,
                title=title,
                pdfUrl=pdf_url,
                publishedIn=url,
                authors=[self.scholar(element) for element in item.find_all(class_="CEURAUTHOR")],
            )
            paper.publish_in(url)
            pages = self.mark_text("CEURPAGES", item)
            if pages:
                paper.pageStart = Convert.page_start(pages)
                paper.pageEnd = Convert.page_end(pages)
            # the paper belongs to the CEURSESSION heading before it, see issue 54
            session = item.find_previous(class_="CEURSESSION")
            if session is not None:
                paper.session = Convert.text(session.get_text()) or None
    return paper

papers(url, result)

get the papers of the volume

Parameters:

Name Type Description Default
url str

the url of the volume

required
result MappingResult

the mapping result to report issues to

required

Returns:

Type Description
List[Paper]

List[Paper]: the papers in the order of the table of contents

Source code in ceursemantify/volume_html.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
def papers(self, url: str, result: MappingResult) -> List[Paper]:
    """
    get the papers of the volume

    Args:
        url: the url of the volume
        result: the mapping result to report issues to

    Returns:
        List[Paper]: the papers in the order of the table of contents
    """
    toc = self.mark("CEURTOC") or self.soup
    papers = []
    pdf_urls = set()
    for item in toc.find_all("li"):
        paper = self.paper(item, url, result)
        # an li around a nested list finds the pdf of its first child again
        if paper is not None and paper.pdfUrl not in pdf_urls:
            pdf_urls.add(paper.pdfUrl)
            papers.append(paper)
    return papers

proceedings()

map the volume page to the proceedings of the research model

Returns:

Name Type Description
MappingResult MappingResult

the proceedings and the issues

Source code in ceursemantify/volume_html.py
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
def proceedings(self) -> MappingResult:
    """
    map the volume page to the proceedings of the research model

    Returns:
        MappingResult: the proceedings and the issues
    """
    result = MappingResult()
    result.issues.extend(self.issues)
    if self.jsonld_document is not None:
        result.target = self.jsonld().proceedings(self.jsonld_document)
        return result
    proceedings = Proceedings(publisher=Publisher(name=self.publisher_name))
    proceedings.volumeNumber = self.volume_number_of(result)
    if proceedings.volumeNumber is not None:
        proceedings.url = f"{self.base_url}/Vol-{proceedings.volumeNumber}/"
        proceedings.archive = f"{self.base_url}/ftp-dir/Vol-{proceedings.volumeNumber}.zip"
    proceedings.urn = self.required("CEURURN", result)
    proceedings.title = self.required("CEURFULLTITLE", result) or self.mark_text("CEURVOLTITLE")
    # the title names the edition by year or ordinal, see issue 22
    if proceedings.title and not self.edition_pattern.search(proceedings.title):
        result.issues.append(
            MappingIssue("Proceedings", "CEURFULLTITLE", f"neither year nor ordinal in {proceedings.title}")
        )
    pub_date = self.required("CEURPUBDATE", result)
    if pub_date:
        try:
            proceedings.pubDate = Convert.iso_date(pub_date)
            # the plausible range of a publication date, see issue 58
            today = date.today().isoformat()
            if not self.first_pub_date <= proceedings.pubDate <= today:
                result.issues.append(
                    MappingIssue(
                        "Proceedings",
                        "CEURPUBDATE",
                        f"{proceedings.pubDate} is not between {self.first_pub_date} and {today}",
                    )
                )
        except ValueError as error:
            result.issues.append(MappingIssue("Proceedings", "CEURPUBDATE", str(error)))
    pub_year = self.mark_text("CEURPUBYEAR")
    if pub_year:
        try:
            proceedings.pubYear = Convert.integer(pub_year)
        except ValueError as error:
            result.issues.append(MappingIssue("Proceedings", "CEURPUBYEAR", str(error)))
    # the numbers of the summary sentence of the Vol-XXX template, see issue 54
    for css_class, field_name in [
        ("CEURSUBMITTEDPAPERS", "submittedPapers"),
        ("CEURACCEPTEDPAPERS", "acceptedPapers"),
    ]:
        count = self.mark_text(css_class)
        if count:
            try:
                setattr(proceedings, field_name, Convert.integer(count))
            except ValueError as error:
                result.issues.append(MappingIssue("Proceedings", css_class, str(error)))
    license_element = self.mark("CEURLIC")
    if license_element is not None:
        proceedings.license = self.link_of(license_element) or self.mark_text("CEURLIC")
    proceedings.event = self.event(result)
    proceedings.editors = self.editors()
    if proceedings.url:
        proceedings.papers = self.papers(proceedings.url, result)
    result.target = proceedings
    return result

read_embedded(path)

read the JSON-LD record of a page, the source of the mapping instead of the markup, see issue 28: index.jsonld beside the page is the record that the DOI registration writes, for every page whatever its state, see issues 42 and 49; the fence of an embedded or generated page is the copy and is read only when there is no index.jsonld

Parameters:

Name Type Description Default
path str

the path of the index.html

required
Source code in ceursemantify/volume_html.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def read_embedded(self, path: str) -> None:
    """
    read the JSON-LD record of a page, the source of the mapping instead of the markup, see issue 28:
    index.jsonld beside the page is the record that the DOI registration writes, for every page
    whatever its state, see issues 42 and 49; the fence of an embedded or generated page is the
    copy and is read only when there is no index.jsonld

    Args:
        path: the path of the index.html
    """
    document = None
    jsonld_path = os.path.join(os.path.dirname(path), "index.jsonld")
    marked = self.embedded_pattern.search(self.text) is not None
    if os.path.isfile(jsonld_path):
        with open(jsonld_path, "r", encoding="utf-8") as jsonld_file:
            document = json.load(jsonld_file)
    elif marked:
        match = self.fence_pattern.search(self.text)
        if match and match.group(1).strip():
            document = json.loads(match.group(1))
    if document is None and not marked:
        return
    self.embedded = True
    if document is None:
        self.issues.append(
            MappingIssue(
                "Proceedings", "CEURSTATE", "embedded, but neither the fence nor index.jsonld holds the JSON-LD"
            )
        )
    self.jsonld_document = document

required(css_class, result)

get the text of a mark that every volume page must have

Parameters:

Name Type Description Default
css_class str

the mark

required
result MappingResult

the mapping result to report a missing mark to

required

Returns:

Type Description
Optional[str]

Optional[str]: the text, None if the mark is missing

Source code in ceursemantify/volume_html.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def required(self, css_class: str, result: MappingResult) -> Optional[str]:
    """
    get the text of a mark that every volume page must have

    Args:
        css_class: the mark
        result: the mapping result to report a missing mark to

    Returns:
        Optional[str]: the text, None if the mark is missing
    """
    text = self.mark_text(css_class)
    if text is None:
        result.issues.append(MappingIssue("Proceedings", css_class, "mark missing"))
    return text

scholar(element)

get the scholar of the given CEURVOLEDITOR or CEURAUTHOR element

Parameters:

Name Type Description Default
element Tag

the element with the name, optionally linked to an ORCID

required

Returns:

Name Type Description
Scholar Scholar

the scholar

Source code in ceursemantify/volume_html.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
def scholar(self, element: Tag) -> Scholar:
    """
    get the scholar of the given CEURVOLEDITOR or CEURAUTHOR element

    Args:
        element: the element with the name, optionally linked to an ORCID

    Returns:
        Scholar: the scholar
    """
    scholar = Scholar.of_full_name(element.get_text())
    for url in [element.get("itemid"), self.link_of(element)]:
        if url and "orcid.org/" in url:
            scholar.orcid = url.rstrip("/").rsplit("/", 1)[1]
            break
    return scholar

volume_number_of(result)

get the volume number of the CEURVOLNR mark

Parameters:

Name Type Description Default
result MappingResult

the mapping result to report a missing or unparsed mark to

required

Returns:

Type Description
Optional[int]

Optional[int]: the volume number, None if the mark is missing or unparsed

Source code in ceursemantify/volume_html.py
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
def volume_number_of(self, result: MappingResult) -> Optional[int]:
    """
    get the volume number of the CEURVOLNR mark

    Args:
        result: the mapping result to report a missing or unparsed mark to

    Returns:
        Optional[int]: the volume number, None if the mark is missing or unparsed
    """
    volume_number = None
    volume_mark = self.required("CEURVOLNR", result)
    if volume_mark:
        try:
            volume_number = Convert.volume_number(volume_mark)
        except ValueError as error:
            result.issues.append(MappingIssue("Proceedings", "CEURVOLNR", str(error)))
    return volume_number