StrictDoc Documentation
strictdoc/server/routers/main_router.py
Source file coverage
Path:
strictdoc/server/routers/main_router.py
Lines:
4923
Non-empty lines:
4530
Non-empty lines covered with requirements:
4530 / 4530 (100.0%)
Functions:
91
Functions covered by requirements:
91 / 91 (100.0%)
1
import asyncio
2
import copy
3
import datetime
4
import os
5
import re
6
import types
7
import uuid
8
from collections import defaultdict
9
from mimetypes import guess_type
10
from pathlib import Path
11
from typing import Any, Dict, Iterator, List, Optional, Union
12
from urllib.parse import quote
13
 
14
from fastapi import APIRouter, Depends, FastAPI, Form, HTTPException, UploadFile
15
from reqif.models.error_handling import ReqIFXMLParsingError
16
from reqif.parser import ReqIFParser
17
from reqif.unparser import ReqIFUnparser
18
from starlette.background import BackgroundTask
19
from starlette.datastructures import FormData
20
from starlette.requests import Request
21
from starlette.responses import (
22
    FileResponse,
23
    HTMLResponse,
24
    RedirectResponse,
25
    Response,
26
)
27
from starlette.websockets import WebSocket, WebSocketDisconnect
28
 
29
from strictdoc.backend.json.json_generator import JSONGenerator
30
from strictdoc.backend.markdown.writer import SDMarkdownWriter
31
from strictdoc.backend.reqif.p01_sdoc.reqif_to_sdoc_converter import (
32
    P01_ReqIFToSDocConverter,
33
)
34
from strictdoc.backend.reqif.p01_sdoc.sdoc_to_reqif_converter import (
35
    P01_SDocToReqIFObjectConverter,
36
)
37
from strictdoc.backend.sdoc.errors.document_tree_error import DocumentTreeError
38
from strictdoc.backend.sdoc.models.document import SDocDocument
39
from strictdoc.backend.sdoc.models.document_grammar import (
40
    DocumentGrammar,
41
)
42
from strictdoc.backend.sdoc.models.grammar_element import (
43
    GrammarElement,
44
    GrammarElementField,
45
    RequirementFieldType,
46
)
47
from strictdoc.backend.sdoc.models.model import (
48
    SDocExtendedElementIF,
49
    SDocNodeIF,
50
)
51
from strictdoc.backend.sdoc.models.node import (
52
    SDocNode,
53
)
54
from strictdoc.backend.sdoc.writer import SDWriter
55
from strictdoc.backend.sdoc_source_code.models.source_file_info import (
56
    SourceFileTraceabilityInfo,
57
)
58
from strictdoc.core.analyzers.document_stats import DocumentTreeStats
59
from strictdoc.core.analyzers.document_uid_analyzer import DocumentUIDAnalyzer
60
from strictdoc.core.document_meta import DocumentMeta
61
from strictdoc.core.document_tree import DocumentTree
62
from strictdoc.core.feature import Feature, FeatureContext
63
from strictdoc.core.project_config import ProjectConfig
64
from strictdoc.core.query_engine.query_object import Query, QueryObject
65
from strictdoc.core.query_engine.query_reader import QueryReader
66
from strictdoc.core.traceability_index_builder import TraceabilityIndexBuilder
67
from strictdoc.core.transforms.constants import NodeCreationOrder
68
from strictdoc.core.transforms.delete_requirement import (
69
    DeleteRequirementCommand,
70
)
71
from strictdoc.core.transforms.update_document_config import (
72
    UpdateDocumentConfigTransform,
73
)
74
from strictdoc.core.transforms.update_grammar import UpdateGrammarCommand
75
from strictdoc.core.transforms.update_grammar_element import (
76
    UpdateGrammarElementCommand,
77
)
78
from strictdoc.core.transforms.update_included_document import (
79
    UpdateIncludedDocumentTransform,
80
)
81
from strictdoc.core.transforms.update_requirement import (
82
    CreateNodeInfo,
83
    CreateOrUpdateNodeCommand,
84
    CreateOrUpdateNodeResult,
85
    UpdateNodeInfo,
86
)
87
from strictdoc.core.transforms.validation_error import (
88
    MultipleValidationError,
89
    MultipleValidationErrorAsList,
90
)
91
from strictdoc.export.html.document_type import DocumentType
92
from strictdoc.export.html.form_objects.document_config_form_object import (
93
    DocumentConfigFormObject,
94
    DocumentMetadataFormField,
95
)
96
from strictdoc.export.html.form_objects.grammar_element_form_object import (
97
    GrammarElementFormObject,
98
)
99
from strictdoc.export.html.form_objects.grammar_form_object import (
100
    GrammarFormObject,
101
)
102
from strictdoc.export.html.form_objects.included_document_form_object import (
103
    IncludedDocumentFormObject,
104
)
105
from strictdoc.export.html.form_objects.requirement_form_object import (
106
    RequirementFormField,
107
    RequirementFormFieldType,
108
    RequirementFormObject,
109
    RequirementReferenceFormField,
110
    deduplicate_comma_separated_value,
111
)
112
from strictdoc.export.html.generators.view_objects.document_screen_view_object import (
113
    DocumentScreenViewObject,
114
)
115
from strictdoc.export.html.generators.view_objects.server_error_view_object import (
116
    ServerErrorViewObject,
117
)
118
from strictdoc.export.html.html_generator import HTMLGenerator
119
from strictdoc.export.html.html_templates import HTMLTemplates, JinjaEnvironment
120
from strictdoc.export.html.renderers.link_renderer import LinkRenderer
121
from strictdoc.export.html.renderers.markup_renderer import MarkupRenderer
122
from strictdoc.features.export.export_action import ExportAction
123
from strictdoc.features.html2pdf.generator import (
124
    DocumentHTML2PDFGenerator,
125
)
126
from strictdoc.features.html2pdf.pdf_print_driver import (
127
    PDFPrintDriver,
128
    PDFPrintDriverException,
129
)
130
from strictdoc.features.nestor.view_object import (
131
    NestorViewObject,
132
)
133
from strictdoc.features.project_index.view_object import (
134
    ProjectTreeViewObject,
135
)
136
from strictdoc.features.search.view_object import (
137
    SearchScreenViewObject,
138
)
139
from strictdoc.helpers.cast import assert_cast
140
from strictdoc.helpers.file_modification_time import (
141
    get_file_modification_time,
142
    set_file_modification_time,
143
)
144
from strictdoc.helpers.mid import MID
145
from strictdoc.helpers.parallelizer import NullParallelizer
146
from strictdoc.helpers.path_filter import PathFilter
147
from strictdoc.helpers.paths import SDocRelativePath
148
from strictdoc.helpers.string import (
149
    create_safe_acronym,
150
    is_safe_alphanumeric_string,
151
    sanitize_html_form_field,
152
)
153
from strictdoc.helpers.timing import measure_performance
154
from strictdoc.server.document_watcher import (
155
    DocumentWatcher,
156
    get_watched_document_extensions,
157
)
158
from strictdoc.server.error_object import ErrorObject
159
from strictdoc.server.helpers.hierarchical_rw_lock_manager import (
160
    HierarchicalRWLockManager,
161
)
162
from strictdoc.server.helpers.http import request_is_for_non_modified_file
163
from strictdoc.server.helpers.turbo import render_turbo_stream
164
 
165
HTTP_STATUS_BAD_REQUEST = 400
166
HTTP_STATUS_NOT_FOUND = 404
167
HTTP_STATUS_PRECONDITION_FAILED = 412
168
HTTP_STATUS_INTERNAL_SERVER_ERROR = 500
169
 
170
AUTOCOMPLETE_LIMIT = 50
171
 
172
 
173
def search_query_contains_markers(query: str) -> bool:
174
    # Query mode markers are intentionally broad to keep behavior deterministic
175
    # for expression-like input.
176
    if "node." in query:
177
        return True
178
    if ("(" in query and ")" in query) or "==" in query or "!=" in query:
179
        return True
180
    if re.search(r'\[\s*"[^"]+"\s*\]', query):
181
        return True
182
    return False
183
 
184
 
185
def parse_plain_text_search_query(
186
    query: str,
187
) -> tuple[Optional[str], Optional[re.Pattern[str]]]:
188
    plain_text_query = query.lower()
189
    if (
190
        len(plain_text_query) >= 2
191
        and plain_text_query.startswith('"')
192
        and plain_text_query.endswith('"')
193
    ):
194
        return plain_text_query[1:-1], None
195
 
196
    query_parts = [part for part in plain_text_query.split() if part]
197
    if len(query_parts) == 0:
198
        return None, None
199
    wildcard_pattern = ".*".join(map(re.escape, query_parts))
200
    return None, re.compile(wildcard_pattern)
201
 
202
 
203
def search_text_matches_plain_text_query(
204
    text: str,
205
    *,
206
    phrase: Optional[str],
207
    pattern: Optional[re.Pattern[str]],
208
) -> bool:
209
    lowered_text = text.lower()
210
    if phrase is not None:
211
        return phrase in lowered_text
212
    if pattern is not None:
213
        return pattern.search(lowered_text) is not None
214
    return False
215
 
216
 
217
def search_node_matches_plain_text_query(
218
    node: SDocExtendedElementIF,
219
    *,
220
    phrase: Optional[str],
221
    pattern: Optional[re.Pattern[str]],
222
) -> bool:
223
    if isinstance(node, SDocNode):
224
        for requirement_field_ in node.enumerate_fields():
225
            field_text = requirement_field_.get_text_value()
226
            if search_text_matches_plain_text_query(
227
                field_text, phrase=phrase, pattern=pattern
228
            ):
229
                return True
230
        return False
231
    if isinstance(node, SourceFileTraceabilityInfo):
232
        if node.source_file is None:
233
            return False
234
        return search_text_matches_plain_text_query(
235
            node.source_file.in_doctree_source_file_rel_path,
236
            phrase=phrase,
237
            pattern=pattern,
238
        )
239
    return False
240
 
241
 
242
def create_main_router(
243
    project_config: ProjectConfig,
244
    *,
245
    app: FastAPI,
246
    lock_manager: HierarchicalRWLockManager,
247
) -> APIRouter:
248
    parallelizer = NullParallelizer()
249
 
250
    # This dictionary is used to track conflicts between concurrently edited
251
    # versions of the same nodes. If a saved node has a version that is older
252
    # than one tracked in this dictionary, StrictDoc raises a validation to a
253
    # user.
254
    # Type signature: [MID, version number]
255
    revisions: Dict[str, int] = defaultdict(int)
256
 
257
    project_config.is_running_on_server = True
258
 
259
    export_action = ExportAction(
260
        project_config=project_config,
261
        parallelizer=parallelizer,
262
    )
263
 
264
    is_small_project = export_action.traceability_index.is_small_project()
265
 
266
    html_templates: HTMLTemplates = HTMLTemplates.create(
267
        project_config=project_config,
268
        enable_caching=not is_small_project,
269
        strictdoc_last_update=export_action.traceability_index.strictdoc_last_update,
270
    )
271
 
272
    html_generator = HTMLGenerator(project_config, html_templates)
273
 
274
    # Server screens contributed by built-in Features (e.g.
275
    # ProjectStatisticsFeature), keyed by the screen_filename() each one
276
    # owns. Built from *all* built-in Features regardless of activation
277
    # (not just project_config.get_features(), which only resolves
278
    # activated ones) so that a request for a known-but-not-activated
279
    # screen can be told apart from an unknown path: the former must
280
    # still return 412, the latter 404. Dispatched from within
281
    # generate_document() below, so every Feature-contributed screen
282
    # still goes through the same shared caching/locking machinery as
283
    # every other document.
284
    server_features_by_screen_filename: Dict[str, Feature] = {
285
        feature_.screen_filename(): feature_
286
        for feature_ in ProjectConfig._builtin_features_by_handle().values()
287
        if feature_.supports_server()
288
    }
289
 
290
    html_generator.export_assets(
291
        traceability_index=export_action.traceability_index,
292
        project_config=project_config,
293
        html_templates=html_templates,
294
        export_output_html_root=project_config.export_output_html_root,
295
    )
296
 
297
    sdoc_writer = SDWriter(project_config)
298
 
299
    def write_document_to_file(document: SDocDocument) -> None:
300
        """
301
        FIXME: Factorize this into an OOP class.
302
        """
303
 
304
        assert isinstance(document, SDocDocument)
305
 
306
        # Inhibit before writing so the watcher's debounce always fires into
307
        # an already-suppressed state — no race window between write and hash.
308
        if document.meta is not None:
309
            document_watcher = getattr(app.state, "document_watcher", None)
310
            if document_watcher is not None:
311
                document_watcher.inhibit_next_change(
312
                    document.meta.input_doc_full_path
313
                )
314
 
315
        if (
316
            document.meta is not None
317
            and document.meta.input_doc_full_path.lower().endswith(
318
                (".md", ".markdown")
319
            )
320
        ):
321
            SDMarkdownWriter.write_to_file(
322
                document, line_width=project_config.document_line_width
323
            )
324
        else:
325
            sdoc_writer.write_to_file(document)
326
 
327
    def env() -> JinjaEnvironment:
328
        return html_templates.jinja_environment()
329
 
330
    @app.exception_handler(404)
331
    async def not_found_handler(request: Request, exc: Exception) -> Response:  # noqa: ARG001
332
        return _error_response(HTTP_STATUS_NOT_FOUND)
333
 
334
    @app.exception_handler(500)
335
    async def internal_error_handler(
336
        request: Request,  # noqa: ARG001
337
        exc: Exception,  # noqa: ARG001
338
    ) -> Response:
339
        return _error_response(HTTP_STATUS_INTERNAL_SERVER_ERROR)
340
 
341
    def read_lock() -> Iterator[None]:
342
        with lock_manager.acquire_global_read():
343
            yield
344
 
345
    def write_lock() -> Iterator[None]:
346
        with lock_manager.acquire_global_write():
347
            yield
348
 
349
    async def parse_form_data(request: Request) -> FormData:
350
        return await request.form()
351
 
352
    router = APIRouter()
353
    read_router = APIRouter(dependencies=[Depends(read_lock)])
354
    write_router = APIRouter(dependencies=[Depends(write_lock)])
355
 
356
    @router.get("/")
357
    def get_root(request: Request) -> Response:
358
        return get_incoming_request(request, "index.html")
359
 
360
    @read_router.get("/actions/show_full_node", response_class=Response)
361
    def node__show_full(reference_mid: str) -> Response:
362
        node: Union[SDocNode] = (
363
            export_action.traceability_index.get_node_by_mid(MID(reference_mid))
364
        )
365
        requirement_document: SDocDocument = assert_cast(
366
            node.get_document(), SDocDocument
367
        )
368
        assert requirement_document.meta is not None
369
        link_renderer = LinkRenderer(
370
            root_path=requirement_document.meta.get_root_path_prefix(),
371
            static_path=project_config.dir_for_sdoc_assets,
372
        )
373
        markup_renderer = MarkupRenderer.create(
374
            markup=requirement_document.config.get_markup(),
375
            traceability_index=export_action.traceability_index,
376
            link_renderer=link_renderer,
377
            html_templates=html_generator.html_templates,
378
            config=project_config,
379
            context_document=requirement_document,
380
        )
381
        view_object = DocumentScreenViewObject(
382
            document_type=DocumentType.DOCUMENT,
383
            document=requirement_document,
384
            traceability_index=export_action.traceability_index,
385
            project_config=project_config,
386
            link_renderer=link_renderer,
387
            markup_renderer=markup_renderer,
388
            jinja_environment=env(),
389
            git_client=html_generator.git_client,
390
        )
391
        output = env().render_template_as_markup(
392
            "actions/node/show_full_node/stream_show_full_node.jinja",
393
            view_object=view_object,
394
            requirement=node,
395
        )
396
        return HTMLResponse(
397
            content=output,
398
            status_code=200,
399
            headers={
400
                "Content-Type": "text/vnd.turbo-stream.html",
401
            },
402
        )
403
 
404
    @read_router.get(
405
        "/actions/document/new_requirement", response_class=Response
406
    )
407
    def get_new_requirement(
408
        reference_mid: str,
409
        whereto: str,
410
        element_type: str,
411
        context_document_mid: str,
412
    ) -> Response:
413
        assert isinstance(reference_mid, str), reference_mid
414
        assert isinstance(whereto, str), whereto
415
        assert isinstance(element_type, str), element_type
416
        assert isinstance(context_document_mid, str), context_document_mid
417
 
418
        assert NodeCreationOrder.is_valid(whereto), whereto
419
 
420
        context_document = export_action.traceability_index.get_node_by_mid(
421
            MID(context_document_mid)
422
        )
423
 
424
        reference_node = export_action.traceability_index.get_node_by_mid(
425
            MID(reference_mid)
426
        )
427
        if not export_action.traceability_index.can_create_node_at(
428
            reference_node, whereto
429
        ):
430
            raise HTTPException(
431
                status_code=403,
432
                detail="Adding nodes is disabled for autogenerated content.",
433
            )
434
 
435
        # Which document becomes the new requirement's parent is based on
436
        # whether the reference node is a root node of an included document or not.
437
        document: SDocDocument
438
        if isinstance(reference_node, SDocDocument):
439
            if whereto == "child":
440
                document = reference_node
441
            else:
442
                document = context_document
443
        else:
444
            document = reference_node.get_document()
445
 
446
        next_uid: Optional[str] = None
447
        if element_type not in ("TEXT", "SECTION"):
448
            document_tree_stats: DocumentTreeStats = (
449
                DocumentUIDAnalyzer.analyze_document_tree(
450
                    export_action.traceability_index
451
                )
452
            )
453
            if (
454
                node_prefix := reference_node.get_prefix_for_new_node(
455
                    element_type
456
                )
457
            ) is not None:
458
                next_uid = document_tree_stats.get_next_requirement_uid(
459
                    node_prefix
460
                )
461
        form_object = RequirementFormObject.create_new(
462
            document=document,
463
            context_document_mid=context_document_mid,
464
            next_uid=next_uid,
465
            element_type=element_type,
466
        )
467
 
468
        target_node_mid = reference_mid
469
 
470
        if whereto == NodeCreationOrder.CHILD:
471
            replace_action = "after"
472
        elif whereto == NodeCreationOrder.BEFORE:
473
            replace_action = "before"
474
        elif whereto == NodeCreationOrder.AFTER:
475
            replace_action = "after"
476
        else:
477
            raise NotImplementedError
478
 
479
        assert document.meta is not None
480
        link_renderer = LinkRenderer(
481
            root_path=document.meta.get_root_path_prefix(),
482
            static_path=project_config.dir_for_sdoc_assets,
483
        )
484
        markup_renderer = MarkupRenderer.create(
485
            markup=document.config.get_markup(),
486
            traceability_index=export_action.traceability_index,
487
            link_renderer=link_renderer,
488
            html_templates=html_generator.html_templates,
489
            config=project_config,
490
            context_document=document,
491
        )
492
        output = env().render_template_as_markup(
493
            "actions/"
494
            "document/"
495
            "create_requirement/"
496
            "stream_new_requirement.jinja.html",
497
            is_new_requirement=True,
498
            renderer=markup_renderer,
499
            form_object=form_object,
500
            reference_mid=reference_mid,
501
            target_node_mid=target_node_mid,
502
            document_type=DocumentType.DOCUMENT,
503
            whereto=whereto,
504
            replace_action=replace_action,
505
        )
506
 
507
        return HTMLResponse(
508
            content=output,
509
            status_code=200,
510
            headers={
511
                "Content-Type": "text/vnd.turbo-stream.html",
512
            },
513
        )
514
 
515
    @read_router.get(
516
        "/actions/document/clone_requirement", response_class=Response
517
    )
518
    def get_clone_requirement(
519
        reference_mid: str, context_document_mid: str
520
    ) -> Response:
521
        assert isinstance(reference_mid, str), reference_mid
522
 
523
        reference_node = export_action.traceability_index.get_node_by_mid(
524
            MID(reference_mid)
525
        )
526
        reference_requirement: SDocNode = assert_cast(reference_node, SDocNode)
527
        if not export_action.traceability_index.can_clone_node(
528
            reference_requirement
529
        ):
530
            raise HTTPException(
531
                status_code=403,
532
                detail="Cloning is disabled for autogenerated content.",
533
            )
534
 
535
        document: Optional[SDocDocument] = (
536
            reference_node
537
            if isinstance(reference_node, SDocDocument)
538
            else reference_node.get_document()
539
        )
540
        document_tree_stats: DocumentTreeStats = (
541
            DocumentUIDAnalyzer.analyze_document_tree(
542
                export_action.traceability_index
543
            )
544
        )
545
        next_uid: str = ""
546
        if (node_prefix := reference_node.get_prefix()) is not None:
547
            next_uid = document_tree_stats.get_next_requirement_uid(node_prefix)
548
 
549
        form_object: RequirementFormObject = (
550
            RequirementFormObject.clone_from_requirement(
551
                requirement=reference_requirement,
552
                context_document_mid=context_document_mid,
553
                clone_uid=next_uid,
554
            )
555
        )
556
 
557
        target_node_mid = reference_mid
558
 
559
        whereto = NodeCreationOrder.AFTER
560
        replace_action = "after"
561
 
562
        assert document is not None
563
        assert document.meta is not None
564
        link_renderer = LinkRenderer(
565
            root_path=document.meta.get_root_path_prefix(),
566
            static_path=project_config.dir_for_sdoc_assets,
567
        )
568
        markup_renderer = MarkupRenderer.create(
569
            markup=document.config.get_markup(),
570
            traceability_index=export_action.traceability_index,
571
            link_renderer=link_renderer,
572
            html_templates=html_generator.html_templates,
573
            config=project_config,
574
            context_document=document,
575
        )
576
        output = env().render_template_as_markup(
577
            "actions/"
578
            "document/"
579
            "create_requirement/"
580
            "stream_new_requirement.jinja.html",
581
            is_new_requirement=True,
582
            renderer=markup_renderer,
583
            form_object=form_object,
584
            reference_mid=reference_mid,
585
            target_node_mid=target_node_mid,
586
            document_type=DocumentType.DOCUMENT,
587
            whereto=whereto,
588
            replace_action=replace_action,
589
        )
590
 
591
        return HTMLResponse(
592
            content=output,
593
            status_code=200,
594
            headers={
595
                "Content-Type": "text/vnd.turbo-stream.html",
596
            },
597
        )
598
 
599
    @write_router.post(
600
        "/actions/document/create_requirement", response_class=Response
601
    )
602
    def create_requirement(
603
        request_form_data: FormData = Depends(parse_form_data),
604
    ) -> Response:
605
        request_dict: Dict[str, str] = dict(request_form_data)
606
        requirement_mid: str = request_dict["requirement_mid"]
607
        document_mid: str = request_dict["document_mid"]
608
        context_document_mid: str = request_dict["context_document_mid"]
609
        reference_mid: str = request_dict["reference_mid"]
610
        whereto: str = request_dict["whereto"]
611
        document: SDocDocument = (
612
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
613
        )
614
        editing_context_document: SDocDocument = (
615
            export_action.traceability_index.get_node_by_mid(
616
                MID(context_document_mid)
617
            )
618
        )
619
        reference_node = export_action.traceability_index.get_node_by_mid(
620
            MID(reference_mid)
621
        )
622
        if not export_action.traceability_index.can_create_node_at(
623
            reference_node, whereto
624
        ):
625
            raise HTTPException(
626
                status_code=403,
627
                detail="Adding nodes is disabled for autogenerated content.",
628
            )
629
 
630
        form_object: RequirementFormObject = (
631
            RequirementFormObject.create_from_request(
632
                is_new=True,
633
                requirement_mid=requirement_mid,
634
                request_form_data=request_form_data,
635
                document=document,
636
                existing_requirement_uid=None,
637
            )
638
        )
639
        form_object.validate(
640
            traceability_index=export_action.traceability_index,
641
            context_document=document,
642
            config=project_config,
643
            existing_revision=0,
644
        )
645
 
646
        if not form_object.any_errors():
647
            command = CreateOrUpdateNodeCommand(
648
                form_object=form_object,
649
                node_info=CreateNodeInfo(
650
                    whereto=whereto,
651
                    requirement_mid=requirement_mid,
652
                    reference_mid=reference_mid,
653
                ),
654
                traceability_index=export_action.traceability_index,
655
                project_config=project_config,
656
            )
657
            command.perform()
658
 
659
        if form_object.any_errors():
660
            assert document.meta is not None
661
            link_renderer = LinkRenderer(
662
                root_path=document.meta.get_root_path_prefix(),
663
                static_path=project_config.dir_for_sdoc_assets,
664
            )
665
            markup_renderer = MarkupRenderer.create(
666
                markup=document.config.get_markup(),
667
                traceability_index=export_action.traceability_index,
668
                link_renderer=link_renderer,
669
                html_templates=html_generator.html_templates,
670
                config=project_config,
671
                context_document=document,
672
            )
673
            output = env().render_template_as_markup(
674
                "actions/"
675
                "document/"
676
                "create_requirement/"
677
                "stream_new_requirement.jinja.html",
678
                is_new_requirement=True,
679
                renderer=markup_renderer,
680
                form_object=form_object,
681
                reference_mid=reference_mid,
682
                target_node_mid=requirement_mid,
683
                document_type=DocumentType.DOCUMENT,
684
                whereto=whereto,
685
                replace_action="replace",
686
            )
687
            return HTMLResponse(
688
                content=output,
689
                status_code=422,
690
                headers={
691
                    "Content-Type": "text/vnd.turbo-stream.html",
692
                },
693
            )
694
 
695
        # Saving new content to .SDoc files.
696
        write_document_to_file(document)
697
        if document != editing_context_document:
698
            write_document_to_file(editing_context_document)
699
 
700
        # Exporting the updated document to HTML. Note that this happens after
701
        # the traceability index last update marker has been updated. This way
702
        # the generated HTML file is newer than the traceability index.
703
        html_generator.export_single_document_with_performance(
704
            document=document,
705
            traceability_index=export_action.traceability_index,
706
            specific_documents=(DocumentType.DOCUMENT,),
707
        )
708
 
709
        assert document.meta is not None
710
        link_renderer = LinkRenderer(
711
            root_path=document.meta.get_root_path_prefix(),
712
            static_path=project_config.dir_for_sdoc_assets,
713
        )
714
        markup_renderer = MarkupRenderer.create(
715
            markup=document.config.get_markup(),
716
            traceability_index=export_action.traceability_index,
717
            link_renderer=link_renderer,
718
            html_templates=html_generator.html_templates,
719
            config=project_config,
720
            context_document=document,
721
        )
722
 
723
        view_object = DocumentScreenViewObject(
724
            document_type=DocumentType.DOCUMENT,
725
            document=editing_context_document,
726
            traceability_index=export_action.traceability_index,
727
            project_config=project_config,
728
            link_renderer=link_renderer,
729
            markup_renderer=markup_renderer,
730
            jinja_environment=env(),
731
            git_client=html_generator.git_client,
732
        )
733
 
734
        output = view_object.render_updated_screen()
735
 
736
        return HTMLResponse(
737
            content=output,
738
            status_code=200,
739
            headers={
740
                "Content-Type": "text/vnd.turbo-stream.html",
741
            },
742
        )
743
 
744
    @read_router.get(
745
        "/actions/document/edit_requirement", response_class=Response
746
    )
747
    def get_edit_requirement(
748
        node_id: str, context_document_mid: str
749
    ) -> Response:
750
        """
751
        @relation(SDOC-SRS-55, scope=function)
752
        """
753
 
754
        requirement: SDocNode = (
755
            export_action.traceability_index.get_node_by_mid(MID(node_id))
756
        )
757
        if not export_action.traceability_index.can_edit_node(requirement):
758
            raise HTTPException(
759
                status_code=403,
760
                detail="Editing is disabled for autogenerated content.",
761
            )
762
 
763
        revision = revisions[requirement.reserved_mid.get_string_value()]
764
 
765
        form_object: RequirementFormObject = (
766
            RequirementFormObject.create_from_requirement(
767
                requirement=requirement,
768
                revision=revision,
769
                context_document_mid=context_document_mid,
770
            )
771
        )
772
        document: SDocDocument = assert_cast(
773
            requirement.get_document(), SDocDocument
774
        )
775
        assert document.meta is not None
776
        link_renderer = LinkRenderer(
777
            root_path=document.meta.get_root_path_prefix(),
778
            static_path=project_config.dir_for_sdoc_assets,
779
        )
780
        markup_renderer = MarkupRenderer.create(
781
            markup=document.config.get_markup(),
782
            traceability_index=export_action.traceability_index,
783
            link_renderer=link_renderer,
784
            html_templates=html_generator.html_templates,
785
            config=project_config,
786
            context_document=document,
787
        )
788
        output = env().render_template_as_markup(
789
            "actions/"
790
            "document/"
791
            "edit_requirement/"
792
            "stream_edit_requirement.jinja.html",
793
            is_new_requirement=False,
794
            renderer=markup_renderer,
795
            form_object=form_object,
796
            document_type=DocumentType.DOCUMENT,
797
        )
798
        return HTMLResponse(
799
            content=output,
800
            status_code=200,
801
            headers={
802
                "Content-Type": "text/vnd.turbo-stream.html",
803
            },
804
        )
805
 
806
    @read_router.get(
807
        "/reset_uid",
808
        response_class=Response,
809
    )
810
    def reset_uid(reference_mid: str) -> Response:
811
        document_tree_stats: DocumentTreeStats = (
812
            DocumentUIDAnalyzer.analyze_document_tree(
813
                export_action.traceability_index
814
            )
815
        )
816
        reference_node = export_action.traceability_index.get_node_by_mid_weak(
817
            MID(reference_mid)
818
        )
819
        next_uid: str = ""
820
        if (
821
            isinstance(reference_node, SDocNode)
822
            and reference_node.node_type == "SECTION"
823
        ):
824
            document: SDocDocument = assert_cast(
825
                reference_node.get_document(), SDocDocument
826
            )
827
            document_acronym = create_safe_acronym(document.title)
828
            next_uid = document_tree_stats.get_auto_section_uid(
829
                document_acronym, reference_node
830
            )
831
        elif isinstance(reference_node, SDocNode):
832
            if (node_prefix := reference_node.get_prefix()) is not None:
833
                next_uid = document_tree_stats.get_next_requirement_uid(
834
                    node_prefix
835
                )
836
        else:
837
            raise NotImplementedError(reference_node)  # pragma: no cover
838
 
839
        uid_form_field: RequirementFormField = RequirementFormField(
840
            field_mid=MID.create(),
841
            field_name="UID",
842
            field_type=RequirementFormFieldType.SINGLELINE,
843
            field_value=next_uid,
844
        )
845
        output = env().render_template_as_markup(
846
            "components/form/row/row_uid_with_reset/stream.jinja",
847
            next_uid=next_uid,
848
            reference_mid=reference_mid,
849
            uid_form_field=uid_form_field,
850
        )
851
        return HTMLResponse(
852
            content=output,
853
            status_code=200,
854
            headers={
855
                "Content-Type": "text/vnd.turbo-stream.html",
856
            },
857
        )
858
 
859
    @write_router.post("/actions/document/update_requirement")
860
    def document__update_requirement(
861
        request_form_data: FormData = Depends(parse_form_data),
862
    ) -> Response:
863
        """
864
        @relation(SDOC-SRS-55, scope=function)
865
        """
866
 
867
        request_dict = dict(request_form_data)
868
        requirement_mid = request_dict["requirement_mid"]
869
        requirement: SDocNode = (
870
            export_action.traceability_index.get_node_by_mid(
871
                MID(requirement_mid)
872
            )
873
        )
874
        if not export_action.traceability_index.can_edit_node(requirement):
875
            raise HTTPException(
876
                status_code=403,
877
                detail="Editing is disabled for autogenerated content.",
878
            )
879
 
880
        document = assert_cast(requirement.get_document(), SDocDocument)
881
 
882
        assert isinstance(requirement_mid, str) and len(requirement_mid) > 0, (
883
            f"{requirement_mid}"
884
        )
885
 
886
        form_object: RequirementFormObject = (
887
            RequirementFormObject.create_from_request(
888
                is_new=False,
889
                requirement_mid=requirement_mid,
890
                request_form_data=request_form_data,
891
                document=document,
892
                existing_requirement_uid=requirement.reserved_uid,
893
            )
894
        )
895
        existing_revision = revisions[form_object.requirement_mid]
896
 
897
        form_object.validate(
898
            traceability_index=export_action.traceability_index,
899
            context_document=document,
900
            config=project_config,
901
            existing_revision=existing_revision,
902
        )
903
 
904
        update_requirement_command_result_or_none: Optional[
905
            CreateOrUpdateNodeResult
906
        ] = None
907
        if not form_object.any_errors():
908
            update_command = CreateOrUpdateNodeCommand(
909
                form_object=form_object,
910
                node_info=UpdateNodeInfo(node_to_update=requirement),
911
                traceability_index=export_action.traceability_index,
912
                project_config=project_config,
913
            )
914
 
915
            update_requirement_command_result_or_none = update_command.perform()
916
 
917
        link_renderer: LinkRenderer
918
        markup_renderer: MarkupRenderer
919
        assert document.meta is not None
920
        if form_object.any_errors():
921
            link_renderer = LinkRenderer(
922
                root_path=document.meta.get_root_path_prefix(),
923
                static_path=project_config.dir_for_sdoc_assets,
924
            )
925
            markup_renderer = MarkupRenderer.create(
926
                markup=document.config.get_markup(),
927
                traceability_index=export_action.traceability_index,
928
                link_renderer=link_renderer,
929
                html_templates=html_generator.html_templates,
930
                config=project_config,
931
                context_document=document,
932
            )
933
            output = env().render_template_as_markup(
934
                "actions/"
935
                "document/"
936
                "edit_requirement/"
937
                "stream_edit_requirement.jinja.html",
938
                is_new_requirement=False,
939
                renderer=markup_renderer,
940
                requirement=requirement,
941
                document_type=DocumentType.DOCUMENT,
942
                form_object=form_object,
943
            )
944
            return HTMLResponse(
945
                content=output,
946
                status_code=422,
947
                headers={
948
                    "Content-Type": "text/vnd.turbo-stream.html",
949
                },
950
            )
951
 
952
        update_requirement_command_result: CreateOrUpdateNodeResult = (
953
            assert_cast(
954
                update_requirement_command_result_or_none,
955
                CreateOrUpdateNodeResult,
956
            )
957
        )
958
 
959
        # Saving new content to .SDoc files.
960
        write_document_to_file(document)
961
 
962
        revisions[requirement_mid] += 1
963
 
964
        # Exporting the updated document to HTML. Note that this happens after
965
        # the traceability index last update marker has been updated. This way
966
        # the generated HTML file is newer than the traceability index.
967
        html_generator.export_single_document_with_performance(
968
            document=document,
969
            traceability_index=export_action.traceability_index,
970
            specific_documents=(DocumentType.DOCUMENT,),
971
        )
972
 
973
        link_renderer = LinkRenderer(
974
            root_path=document.meta.get_root_path_prefix(),
975
            static_path=project_config.dir_for_sdoc_assets,
976
        )
977
        markup_renderer = MarkupRenderer.create(
978
            markup=document.config.get_markup(),
979
            traceability_index=export_action.traceability_index,
980
            link_renderer=link_renderer,
981
            html_templates=html_generator.html_templates,
982
            config=project_config,
983
            context_document=document,
984
        )
985
        view_object = DocumentScreenViewObject(
986
            document_type=DocumentType.DOCUMENT,
987
            document=document,
988
            traceability_index=export_action.traceability_index,
989
            project_config=project_config,
990
            link_renderer=link_renderer,
991
            markup_renderer=markup_renderer,
992
            jinja_environment=env(),
993
            git_client=html_generator.git_client,
994
        )
995
 
996
        return HTMLResponse(
997
            content=view_object.render_updated_nodes_and_toc(
998
                update_requirement_command_result.this_document_requirements_to_update,
999
                node_updated=True,
1000
            ),
1001
            status_code=200,
1002
            headers={
1003
                "Content-Type": "text/vnd.turbo-stream.html",
1004
            },
1005
        )
1006
 
1007
    @write_router.delete("/actions/table/delete_node")
1008
    def table__delete_node(
1009
        node_id: str, context_document_mid: str, confirmed: bool = False
1010
    ) -> Response:
1011
        node = export_action.traceability_index.get_node_by_mid(MID(node_id))
1012
        if not isinstance(node, SDocNode):
1013
            raise HTTPException(status_code=404, detail="Node not found.")
1014
        if not export_action.traceability_index.can_delete_node(node):
1015
            raise HTTPException(
1016
                status_code=403,
1017
                detail="Deleting is disabled for autogenerated content.",
1018
            )
1019
 
1020
        if not confirmed:
1021
            try:
1022
                DeleteRequirementCommand(
1023
                    requirement=node,
1024
                    traceability_index=export_action.traceability_index,
1025
                ).validate()
1026
                errors: List[str] = []
1027
            except MultipleValidationErrorAsList as error_:
1028
                errors = error_.errors
1029
 
1030
            output = env().render_template_as_markup(
1031
                "actions/table/delete_node/stream_confirm.jinja",
1032
                node_mid=node_id,
1033
                context_document_mid=context_document_mid,
1034
                errors=errors,
1035
            )
1036
            return HTMLResponse(
1037
                content=output,
1038
                status_code=200 if len(errors) == 0 else 422,
1039
                headers={"Content-Type": "text/vnd.turbo-stream.html"},
1040
            )
1041
 
1042
        document = assert_cast(node.get_document(), SDocDocument)
1043
        editing_context_document = assert_cast(
1044
            export_action.traceability_index.get_node_by_mid(
1045
                MID(context_document_mid)
1046
            ),
1047
            SDocDocument,
1048
        )
1049
 
1050
        try:
1051
            DeleteRequirementCommand(
1052
                requirement=node,
1053
                traceability_index=export_action.traceability_index,
1054
            ).perform()
1055
        except MultipleValidationError:
1056
            return HTMLResponse(
1057
                content="",
1058
                status_code=422,
1059
                headers={"Content-Type": "text/vnd.turbo-stream.html"},
1060
            )
1061
 
1062
        write_document_to_file(document)
1063
        if document != editing_context_document:
1064
            write_document_to_file(editing_context_document)
1065
 
1066
        html_generator.export_single_document_with_performance(
1067
            document=document,
1068
            traceability_index=export_action.traceability_index,
1069
            specific_documents=(DocumentType.DOCUMENT, DocumentType.TABLE),
1070
        )
1071
        if document != editing_context_document:
1072
            html_generator.export_single_document_with_performance(
1073
                document=editing_context_document,
1074
                traceability_index=export_action.traceability_index,
1075
                specific_documents=(DocumentType.DOCUMENT, DocumentType.TABLE),
1076
            )
1077
 
1078
        table_view_object = DocumentScreenViewObject.create_for_table_screen(
1079
            document=editing_context_document,
1080
            traceability_index=export_action.traceability_index,
1081
            project_config=project_config,
1082
            html_templates=html_generator.html_templates,
1083
            git_client=html_generator.git_client,
1084
            jinja_environment=env(),
1085
        )
1086
        output = render_turbo_stream(
1087
            content=env().render_template_as_markup(
1088
                "screens/document/table/body.jinja",
1089
                view_object=table_view_object,
1090
                content_entries=list(
1091
                    table_view_object.document_content_iterator()
1092
                ),
1093
            ),
1094
            action="replace",
1095
            target="table-content-body",
1096
        )
1097
        output += env().render_template_as_markup(
1098
            "actions/document/_shared/stream_updated_toc.jinja.html",
1099
            view_object=table_view_object,
1100
        )
1101
        output += render_turbo_stream(
1102
            content="",
1103
            action="update",
1104
            target="confirm",
1105
        )
1106
        return HTMLResponse(
1107
            content=output,
1108
            status_code=200,
1109
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1110
        )
1111
 
1112
    @write_router.post("/actions/table/add_node")
1113
    def table__add_node(
1114
        request_form_data: FormData = Depends(parse_form_data),
1115
    ) -> Response:
1116
        request_dict = dict(request_form_data)
1117
        context_document_mid = request_dict["context_document_mid"]
1118
        reference_mid = request_dict["reference_mid"]
1119
        element_type = request_dict["element_type"]
1120
        whereto = request_dict["whereto"]
1121
 
1122
        if not NodeCreationOrder.is_valid(whereto):
1123
            return HTMLResponse(
1124
                content="Unknown node placement.", status_code=400
1125
            )
1126
 
1127
        reference_node = export_action.traceability_index.get_node_by_mid(
1128
            MID(reference_mid)
1129
        )
1130
        if not export_action.traceability_index.can_create_node_at(
1131
            reference_node, whereto
1132
        ):
1133
            return HTMLResponse(
1134
                content="Adding nodes is disabled for this location.",
1135
                status_code=403,
1136
            )
1137
 
1138
        editing_context_document = (
1139
            export_action.traceability_index.get_node_by_mid(
1140
                MID(context_document_mid)
1141
            )
1142
        )
1143
        if isinstance(reference_node, SDocDocument):
1144
            if whereto == NodeCreationOrder.CHILD:
1145
                document = reference_node
1146
            else:
1147
                document = editing_context_document
1148
        else:
1149
            document = assert_cast(reference_node.get_document(), SDocDocument)
1150
 
1151
        assert document.grammar is not None
1152
        if element_type not in document.grammar.elements_by_type:
1153
            return HTMLResponse(content="Unknown node type.", status_code=400)
1154
 
1155
        element = document.grammar.elements_by_type[element_type]
1156
 
1157
        next_uid: Optional[str] = None
1158
        if element_type not in ("TEXT", "SECTION"):
1159
            document_tree_stats: DocumentTreeStats = (
1160
                DocumentUIDAnalyzer.analyze_document_tree(
1161
                    export_action.traceability_index
1162
                )
1163
            )
1164
            if (
1165
                node_prefix := reference_node.get_prefix_for_new_node(
1166
                    element_type
1167
                )
1168
            ) is not None:
1169
                next_uid = document_tree_stats.get_next_requirement_uid(
1170
                    node_prefix
1171
                )
1172
 
1173
        form_object = RequirementFormObject.create_new(
1174
            document=document,
1175
            context_document_mid=context_document_mid,
1176
            next_uid=next_uid,
1177
            element_type=element_type,
1178
        )
1179
 
1180
        for field_name, fields in form_object.fields.items():
1181
            if field_name in ("UID", "MID"):
1182
                continue
1183
            grammar_field = element.fields_map[field_name]
1184
            if grammar_field.required:
1185
                for field in fields:
1186
                    if len(field.field_value) == 0:
1187
                        field.field_value = "TBD"
1188
 
1189
        # Fallback: if still no field has a value (including auto-generated
1190
        # UID/MID), pick the first suitable field by priority so the node is
1191
        # never completely empty. Mirrors the "at least one non-empty" check in
1192
        # RequirementFormObject.validate().
1193
        if not any(
1194
            len(f.field_value) > 0
1195
            for fl in form_object.fields.values()
1196
            for f in fl
1197
        ):
1198
            _PRIORITY_NAMES = ("TITLE", "STATEMENT", "RATIONALE")
1199
            _PRIORITY_TYPES = (
1200
                RequirementFieldType.STRING,
1201
                RequirementFieldType.SINGLE_CHOICE,
1202
                RequirementFieldType.MULTIPLE_CHOICE,
1203
            )
1204
            _fallback_field = None
1205
            for _name in _PRIORITY_NAMES:
1206
                if _name in form_object.fields:
1207
                    _fallback_field = form_object.fields[_name][0]
1208
                    break
1209
            if _fallback_field is None:
1210
                for _type in _PRIORITY_TYPES:
1211
                    for _fn, _fl in form_object.fields.items():
1212
                        if _fn in ("UID", "MID"):
1213
                            continue
1214
                        if element.fields_map[_fn].gef_type == _type:
1215
                            _fallback_field = _fl[0]
1216
                            break
1217
                    if _fallback_field is not None:
1218
                        break
1219
            if _fallback_field is not None:
1220
                _fallback_field.field_value = "TBD"
1221
 
1222
        form_object.validate(
1223
            traceability_index=export_action.traceability_index,
1224
            context_document=document,
1225
            config=project_config,
1226
            existing_revision=0,
1227
        )
1228
        if form_object.any_errors():
1229
            error_messages: List[str] = []
1230
            for field_errors in form_object.errors.values():
1231
                error_messages.extend(field_errors)
1232
            for reference_field in form_object.reference_fields:
1233
                error_messages.extend(reference_field.validation_messages)
1234
            return HTMLResponse(
1235
                content=(
1236
                    error_messages[0]
1237
                    if len(error_messages) > 0
1238
                    else "Unable to create this node."
1239
                ),
1240
                status_code=422,
1241
            )
1242
 
1243
        create_command = CreateOrUpdateNodeCommand(
1244
            form_object=form_object,
1245
            node_info=CreateNodeInfo(
1246
                whereto=whereto,
1247
                requirement_mid=form_object.requirement_mid,
1248
                reference_mid=reference_mid,
1249
            ),
1250
            traceability_index=export_action.traceability_index,
1251
            project_config=project_config,
1252
        )
1253
        create_command.perform()
1254
 
1255
        write_document_to_file(document)
1256
        if document != editing_context_document:
1257
            write_document_to_file(editing_context_document)
1258
 
1259
        html_generator.export_single_document_with_performance(
1260
            document=document,
1261
            traceability_index=export_action.traceability_index,
1262
            specific_documents=(DocumentType.DOCUMENT, DocumentType.TABLE),
1263
        )
1264
        if document != editing_context_document:
1265
            html_generator.export_single_document_with_performance(
1266
                document=editing_context_document,
1267
                traceability_index=export_action.traceability_index,
1268
                specific_documents=(DocumentType.DOCUMENT, DocumentType.TABLE),
1269
            )
1270
 
1271
        table_view_object = DocumentScreenViewObject.create_for_table_screen(
1272
            document=editing_context_document,
1273
            traceability_index=export_action.traceability_index,
1274
            project_config=project_config,
1275
            html_templates=html_generator.html_templates,
1276
            git_client=html_generator.git_client,
1277
            jinja_environment=env(),
1278
        )
1279
        output = render_turbo_stream(
1280
            content=env().render_template_as_markup(
1281
                "screens/document/table/body.jinja",
1282
                view_object=table_view_object,
1283
                content_entries=list(
1284
                    table_view_object.document_content_iterator()
1285
                ),
1286
            ),
1287
            action="replace",
1288
            target="table-content-body",
1289
        )
1290
        output += env().render_template_as_markup(
1291
            "actions/document/_shared/stream_updated_toc.jinja.html",
1292
            view_object=table_view_object,
1293
        )
1294
        output += render_turbo_stream(
1295
            content=(
1296
                '<div id="table-add-node-feedback" hidden '
1297
                f'data-created-node-mid="{form_object.requirement_mid}">'
1298
                "</div>"
1299
            ),
1300
            action="replace",
1301
            target="table-add-node-feedback",
1302
        )
1303
        return HTMLResponse(
1304
            content=output,
1305
            status_code=200,
1306
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1307
        )
1308
 
1309
    @write_router.post("/actions/table/update_node_field")
1310
    def table__update_node_field(
1311
        request_form_data: FormData = Depends(parse_form_data),
1312
    ) -> Response:
1313
        request_dict = dict(request_form_data)
1314
        node_mid_str: str = request_dict["node_mid"]
1315
        field_name: str = request_dict["field_name"]
1316
        field_value: str = request_dict.get("field_value", "")
1317
 
1318
        node: SDocNode = export_action.traceability_index.get_node_by_mid(
1319
            MID(node_mid_str)
1320
        )
1321
        document = assert_cast(node.get_document(), SDocDocument)
1322
        assert document.grammar is not None
1323
        grammar: DocumentGrammar = document.grammar
1324
        element: GrammarElement = grammar.elements_by_type[node.node_type]
1325
 
1326
        if field_name not in element.fields_map:
1327
            return HTMLResponse(
1328
                content=f"Unknown field: {field_name}",
1329
                status_code=400,
1330
            )
1331
        if element.is_field_multiline(field_name):
1332
            return HTMLResponse(
1333
                content=f"Field {field_name} is multiline; use the popup editor",
1334
                status_code=400,
1335
            )
1336
 
1337
        sanitized_value: str = sanitize_html_form_field(
1338
            field_value, multiline=False
1339
        )
1340
 
1341
        old_title: Optional[str] = (
1342
            node.reserved_title if field_name == "TITLE" else None
1343
        )
1344
 
1345
        revision: int = revisions[node_mid_str]
1346
        form_object: RequirementFormObject = (
1347
            RequirementFormObject.create_from_requirement(
1348
                requirement=node,
1349
                revision=revision,
1350
                context_document_mid=document.reserved_mid.get_string_value(),
1351
            )
1352
        )
1353
 
1354
        if field_name in form_object.fields:
1355
            form_object.fields[field_name][0].field_value = sanitized_value
1356
 
1357
        form_object.validate(
1358
            traceability_index=export_action.traceability_index,
1359
            context_document=document,
1360
            config=project_config,
1361
            existing_revision=revision,
1362
        )
1363
        if form_object.any_errors():
1364
            first_error = next(iter(form_object.errors.values()))
1365
            return HTMLResponse(
1366
                content=first_error[0] if first_error else "Validation error",
1367
                status_code=422,
1368
            )
1369
 
1370
        update_command = CreateOrUpdateNodeCommand(
1371
            form_object=form_object,
1372
            node_info=UpdateNodeInfo(node_to_update=node),
1373
            traceability_index=export_action.traceability_index,
1374
            project_config=project_config,
1375
        )
1376
        update_command.perform()
1377
        write_document_to_file(document)
1378
        revisions[node_mid_str] += 1
1379
 
1380
        table_view_object = DocumentScreenViewObject.create_for_table_screen(
1381
            document=document,
1382
            traceability_index=export_action.traceability_index,
1383
            project_config=project_config,
1384
            html_templates=html_generator.html_templates,
1385
            git_client=html_generator.git_client,
1386
            jinja_environment=env(),
1387
        )
1388
 
1389
        if field_name == "TITLE":
1390
            title_presence_changed = bool(old_title) != bool(sanitized_value)
1391
            content_entries = (
1392
                list(table_view_object.document_content_iterator())
1393
                if title_presence_changed
1394
                else []
1395
            )
1396
            output = env().render_template_as_markup(
1397
                "actions/table/update_node_field/stream_update_title_field.jinja.html",
1398
                view_object=table_view_object,
1399
                node=node,
1400
                field_value=sanitized_value,
1401
                title_presence_changed=title_presence_changed,
1402
                content_entries=content_entries,
1403
            )
1404
        else:
1405
            output = env().render_template_as_markup(
1406
                "actions/table/update_node_field/stream_update_node_field.jinja.html",
1407
                view_object=table_view_object,
1408
                node=node,
1409
                field_name=field_name,
1410
                field_value=sanitized_value,
1411
            )
1412
        return HTMLResponse(
1413
            content=output,
1414
            status_code=200,
1415
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1416
        )
1417
 
1418
    @read_router.get(
1419
        "/actions/table/get_node_comments_inline", response_class=Response
1420
    )
1421
    def table__get_node_comments_inline(node_mid: str) -> Response:
1422
        node: SDocNode = export_action.traceability_index.get_node_by_mid(
1423
            MID(node_mid)
1424
        )
1425
        document = assert_cast(node.get_document(), SDocDocument)
1426
        revision: int = revisions[node_mid]
1427
        form_object: RequirementFormObject = (
1428
            RequirementFormObject.create_from_requirement(
1429
                requirement=node,
1430
                revision=revision,
1431
                context_document_mid=document.reserved_mid.get_string_value(),
1432
            )
1433
        )
1434
        output = env().render_template_as_markup(
1435
            "actions/table/get_node_comments_inline/stream_inline_form.jinja.html",
1436
            form_object=form_object,
1437
        )
1438
        return HTMLResponse(
1439
            content=output,
1440
            status_code=200,
1441
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1442
        )
1443
 
1444
    @read_router.get(
1445
        "/actions/table/get_node_relations_inline", response_class=Response
1446
    )
1447
    def table__get_node_relations_inline(node_mid: str) -> Response:
1448
        node: SDocNode = export_action.traceability_index.get_node_by_mid(
1449
            MID(node_mid)
1450
        )
1451
        document = assert_cast(node.get_document(), SDocDocument)
1452
        revision: int = revisions[node_mid]
1453
        form_object: RequirementFormObject = (
1454
            RequirementFormObject.create_from_requirement(
1455
                requirement=node,
1456
                revision=revision,
1457
                context_document_mid=document.reserved_mid.get_string_value(),
1458
            )
1459
        )
1460
        assert document.meta is not None
1461
        link_renderer = LinkRenderer(
1462
            root_path=document.meta.get_root_path_prefix(),
1463
            static_path=project_config.dir_for_sdoc_assets,
1464
        )
1465
 
1466
        # UIDs of relations explicitly declared on this node in its .sdoc data.
1467
        own_relation_uids = {
1468
            r.ref_uid
1469
            for r in node.relations
1470
            if hasattr(r, "ref_uid") and r.ref_uid
1471
        }
1472
        # All nodes linked to this node in both directions by the traceability graph.
1473
        traceability_linked_nodes = (
1474
            export_action.traceability_index.get_parent_requirements(node)
1475
            + export_action.traceability_index.get_children_requirements(node)
1476
        )
1477
        # Nodes present in traceability but not declared on this node —
1478
        # derived connections (e.g. other nodes that reference this one as parent).
1479
        derived_nodes = [
1480
            req
1481
            for req in traceability_linked_nodes
1482
            if req.reserved_uid not in own_relation_uids
1483
        ]
1484
 
1485
        view_object_stub = types.SimpleNamespace(
1486
            render_node_link=lambda req: link_renderer.render_node_link(
1487
                req, document, DocumentType.DOCUMENT
1488
            ),
1489
        )
1490
        output = env().render_template_as_markup(
1491
            "actions/table/get_node_relations_inline/stream_inline_form.jinja.html",
1492
            form_object=form_object,
1493
            derived_nodes=derived_nodes,
1494
            view_object=view_object_stub,
1495
        )
1496
        return HTMLResponse(
1497
            content=output,
1498
            status_code=200,
1499
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1500
        )
1501
 
1502
    @read_router.get(
1503
        "/actions/table/get_node_autocomplete_inline", response_class=Response
1504
    )
1505
    def table__get_node_autocomplete_inline(
1506
        node_mid: str,
1507
        field_name: str,
1508
    ) -> Response:
1509
        node: SDocNode = export_action.traceability_index.get_node_by_mid(
1510
            MID(node_mid)
1511
        )
1512
        document = assert_cast(node.get_document(), SDocDocument)
1513
        assert document.grammar is not None
1514
        grammar: DocumentGrammar = document.grammar
1515
        element: GrammarElement = grammar.elements_by_type[node.node_type]
1516
 
1517
        if field_name not in element.fields_map:
1518
            return HTMLResponse(
1519
                content=f"Unknown field: {field_name}", status_code=400
1520
            )
1521
 
1522
        field: GrammarElementField = element.fields_map[field_name]
1523
        is_multiple_choice: bool = field.gef_type in (
1524
            RequirementFieldType.MULTIPLE_CHOICE,
1525
            RequirementFieldType.TAG,
1526
        )
1527
 
1528
        current_value: str = ""
1529
        if field_name in node.ordered_fields_lookup:
1530
            current_value = node.ordered_fields_lookup[field_name][
1531
                0
1532
            ].get_text_value()
1533
            if is_multiple_choice:
1534
                # The document may already contain duplicate values (e.g.
1535
                # hand-edited, or saved before the autocomplete
1536
                # duplicate-prevention fix). Deduplicate when loading the
1537
                # value into the table cell's edit mode, same as the
1538
                # modal requirement-edit form does.
1539
                current_value = deduplicate_comma_separated_value(current_value)
1540
 
1541
        output = env().render_template_as_markup(
1542
            "actions/table/get_node_autocomplete_inline/stream_inline_form.jinja.html",
1543
            node_mid=node_mid,
1544
            cell_field_name=field_name,
1545
            current_value=current_value,
1546
            document_mid=document.reserved_mid.get_string_value(),
1547
            element_type=node.node_type,
1548
            is_multiple_choice=is_multiple_choice,
1549
        )
1550
        return HTMLResponse(
1551
            content=output,
1552
            status_code=200,
1553
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1554
        )
1555
 
1556
    @read_router.get(
1557
        "/actions/table/get_node_contenteditable_inline",
1558
        response_class=Response,
1559
    )
1560
    def table__get_node_contenteditable_inline(
1561
        node_mid: str,
1562
        field_name: str,
1563
    ) -> Response:
1564
        node: SDocNode = export_action.traceability_index.get_node_by_mid(
1565
            MID(node_mid)
1566
        )
1567
        document = assert_cast(node.get_document(), SDocDocument)
1568
        assert document.grammar is not None
1569
        grammar: DocumentGrammar = document.grammar
1570
        element: GrammarElement = grammar.elements_by_type[node.node_type]
1571
 
1572
        if field_name == "TITLE":
1573
            current_value = node.reserved_title or ""
1574
        elif field_name in node.ordered_fields_lookup:
1575
            current_value = node.ordered_fields_lookup[field_name][
1576
                0
1577
            ].get_text_value()
1578
        else:
1579
            current_value = ""
1580
 
1581
        if field_name in element.fields_map and element.is_field_multiline(
1582
            field_name
1583
        ):
1584
            field_type = "multiline"
1585
            form_action = "/actions/table/update_node_field_multiline"
1586
        else:
1587
            field_type = "singleline"
1588
            form_action = "/actions/table/update_node_field"
1589
 
1590
        output = env().render_template_as_markup(
1591
            "actions/table/get_node_contenteditable_inline/stream_inline_form.jinja.html",
1592
            node_mid=node_mid,
1593
            field_name=field_name,
1594
            current_value=current_value,
1595
            field_type=field_type,
1596
            form_action=form_action,
1597
        )
1598
        return HTMLResponse(
1599
            content=output,
1600
            status_code=200,
1601
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1602
        )
1603
 
1604
    _TABLE_DOC_CONFIG_FIELDS = frozenset(
1605
        {"TITLE", "UID", "VERSION", "CLASSIFICATION", "PREFIX"}
1606
    )
1607
 
1608
    @read_router.get(
1609
        "/actions/table/get_document_config_field_inline",
1610
        response_class=Response,
1611
    )
1612
    def table__get_document_config_field_inline(
1613
        document_mid: str,
1614
        field_name: str,
1615
    ) -> Response:
1616
        document: SDocDocument = (
1617
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1618
        )
1619
        if not export_action.traceability_index.can_edit_document(document):
1620
            raise HTTPException(
1621
                status_code=403,
1622
                detail="Editing is disabled for autogenerated content.",
1623
            )
1624
        if field_name not in _TABLE_DOC_CONFIG_FIELDS:
1625
            raise HTTPException(
1626
                status_code=400, detail=f"Unknown field: {field_name}"
1627
            )
1628
 
1629
        if field_name == "TITLE":
1630
            current_value = document.title or ""
1631
        elif field_name == "UID":
1632
            current_value = document.config.uid or ""
1633
        elif field_name == "VERSION":
1634
            current_value = document.config.version or ""
1635
        elif field_name == "CLASSIFICATION":
1636
            current_value = document.config.classification or ""
1637
        else:  # PREFIX
1638
            current_value = document.config.requirement_prefix or ""
1639
 
1640
        output = env().render_template_as_markup(
1641
            "actions/table/get_document_config_field_inline/stream_inline_form.jinja.html",
1642
            document_mid=document_mid,
1643
            field_name=field_name,
1644
            current_value=current_value,
1645
        )
1646
        return HTMLResponse(
1647
            content=output,
1648
            status_code=200,
1649
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1650
        )
1651
 
1652
    @write_router.post(
1653
        "/actions/table/update_document_config_field",
1654
        response_class=Response,
1655
    )
1656
    def table__update_document_config_field(
1657
        request_form_data: FormData = Depends(parse_form_data),
1658
    ) -> Response:
1659
        request_dict: Dict[str, str] = dict(request_form_data)
1660
        document_mid: str = request_dict["document_mid"]
1661
        field_name: str = request_dict["field_name"]
1662
        field_value: str = request_dict.get("field_value", "")
1663
 
1664
        document: SDocDocument = (
1665
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1666
        )
1667
        if not export_action.traceability_index.can_edit_document(document):
1668
            raise HTTPException(
1669
                status_code=403,
1670
                detail="Editing is disabled for autogenerated content.",
1671
            )
1672
        if field_name not in _TABLE_DOC_CONFIG_FIELDS:
1673
            return HTMLResponse(
1674
                content=f"Unknown field: {field_name}", status_code=400
1675
            )
1676
 
1677
        sanitized_value: str = sanitize_html_form_field(
1678
            field_value, multiline=False
1679
        )
1680
 
1681
        form_object: DocumentConfigFormObject = (
1682
            DocumentConfigFormObject.create_from_document(document=document)
1683
        )
1684
        if field_name == "TITLE":
1685
            form_object.document_title = sanitized_value
1686
        elif field_name == "UID":
1687
            form_object.document_uid = sanitized_value
1688
        elif field_name == "VERSION":
1689
            form_object.document_version = sanitized_value
1690
        elif field_name == "CLASSIFICATION":
1691
            form_object.document_classification = sanitized_value
1692
        else:  # PREFIX
1693
            form_object.document_requirement_prefix = sanitized_value
1694
 
1695
        try:
1696
            update_command = UpdateDocumentConfigTransform(
1697
                form_object=form_object,
1698
                document=document,
1699
                traceability_index=export_action.traceability_index,
1700
            )
1701
            update_command.perform()
1702
        except MultipleValidationError as validation_error:
1703
            errors = validation_error.errors.get(field_name, [])
1704
            error_text = "\n".join(errors) if errors else "Validation error"
1705
            return HTMLResponse(content=error_text, status_code=422)
1706
 
1707
        write_document_to_file(document)
1708
        export_action.traceability_index.update_last_updated()
1709
 
1710
        if field_name == "TITLE":
1711
            display_value = document.title or ""
1712
        elif field_name == "UID":
1713
            display_value = document.config.uid or ""
1714
        elif field_name == "VERSION":
1715
            display_value = document.config.version or ""
1716
        elif field_name == "CLASSIFICATION":
1717
            display_value = document.config.classification or ""
1718
        else:  # PREFIX
1719
            display_value = document.config.requirement_prefix or ""
1720
 
1721
        assert document.meta is not None
1722
        link_renderer = LinkRenderer(
1723
            root_path=document.meta.get_root_path_prefix(),
1724
            static_path=project_config.dir_for_sdoc_assets,
1725
        )
1726
        view_object_stub = types.SimpleNamespace(
1727
            document=document,
1728
            render_local_anchor=link_renderer.render_local_anchor,
1729
        )
1730
 
1731
        output = env().render_template_as_markup(
1732
            "actions/table/update_document_config_field/stream_update.jinja.html",
1733
            document=document,
1734
            field_name=field_name,
1735
            display_value=display_value,
1736
            view_object=view_object_stub,
1737
        )
1738
        return HTMLResponse(
1739
            content=output,
1740
            status_code=200,
1741
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1742
        )
1743
 
1744
    @read_router.get(
1745
        "/actions/table/get_document_custom_meta_inline",
1746
        response_class=Response,
1747
    )
1748
    def table__get_document_custom_meta_inline(
1749
        document_mid: str,
1750
        form_key: str,
1751
        field_name: str = "value",
1752
    ) -> Response:
1753
        document: SDocDocument = (
1754
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1755
        )
1756
        if not export_action.traceability_index.can_edit_document(document):
1757
            raise HTTPException(
1758
                status_code=403,
1759
                detail="Editing is disabled for autogenerated content.",
1760
            )
1761
 
1762
        form_object = DocumentConfigFormObject.create_from_document(
1763
            document=document
1764
        )
1765
        # form_key is a table-form transport key, not a StrictDoc MID. Nested
1766
        # form keys use underscores because parse_form_data only accepts
1767
        # letters, digits, and underscores in bracketed field-name segments.
1768
        # The numeric suffix identifies the current row position for this render.
1769
        form_key_match = re.fullmatch(r"custom_meta_(\d+)", form_key)
1770
        if form_key_match is None:
1771
            return HTMLResponse(
1772
                content=f"Invalid custom metadata form key: {form_key}",
1773
                status_code=400,
1774
            )
1775
        metadata_index = int(form_key_match.group(1))
1776
        if metadata_index >= len(form_object.custom_metadata_fields):
1777
            return HTMLResponse(
1778
                content=f"Unknown custom metadata form key: {form_key}",
1779
                status_code=404,
1780
            )
1781
        metadata_field = form_object.custom_metadata_fields[metadata_index]
1782
        if field_name not in ("name", "value"):
1783
            return HTMLResponse(
1784
                content=f"Invalid custom metadata field name: {field_name}",
1785
                status_code=400,
1786
            )
1787
 
1788
        output = env().render_template_as_markup(
1789
            "actions/table/get_document_custom_meta_inline/stream_inline_form.jinja.html",
1790
            form_key=form_key,
1791
            field_name=field_name,
1792
            field_label=metadata_field.field_name,
1793
            field_value=metadata_field.field_value,
1794
            errors=[],
1795
            name_errors=[],
1796
            value_errors=[],
1797
        )
1798
        return HTMLResponse(
1799
            content=output,
1800
            status_code=200,
1801
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1802
        )
1803
 
1804
    @read_router.get(
1805
        "/actions/table/get_document_custom_meta_new_inline",
1806
        response_class=Response,
1807
    )
1808
    def table__get_document_custom_meta_new_inline(
1809
        document_mid: str,
1810
    ) -> Response:
1811
        document: SDocDocument = (
1812
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1813
        )
1814
        if not export_action.traceability_index.can_edit_document(document):
1815
            raise HTTPException(
1816
                status_code=403,
1817
                detail="Editing is disabled for autogenerated content.",
1818
            )
1819
 
1820
        # The new row key is local to the current form render. A separate prefix
1821
        # lets the POST endpoint distinguish an unsaved Add row from an existing
1822
        # positional metadata row without introducing a persistent identifier.
1823
        form_key = (
1824
            f"new_custom_meta_{len(document.config.get_custom_metadata())}"
1825
        )
1826
        output = env().render_template_as_markup(
1827
            "actions/table/get_document_custom_meta_new_inline/stream_inline_form.jinja.html",
1828
            form_key=form_key,
1829
            field_name="",
1830
            field_value="",
1831
            errors=[],
1832
            name_errors=[],
1833
            value_errors=[],
1834
        )
1835
        return HTMLResponse(
1836
            content=output,
1837
            status_code=200,
1838
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
1839
        )
1840
 
1841
    @write_router.post(
1842
        "/actions/table/update_document_custom_meta",
1843
        response_class=Response,
1844
    )
1845
    def table__update_document_custom_meta(
1846
        request_form_data: FormData = Depends(parse_form_data),
1847
    ) -> Response:
1848
        request_dict: Dict[str, str] = dict(request_form_data)
1849
        document_mid: str = request_dict["document_mid"]
1850
        active_form_key: str = request_dict["active_form_key"]
1851
        active_field_name: str = request_dict.get("active_field_name", "value")
1852
        action: Optional[str] = request_dict.get("action")
1853
        document: SDocDocument = (
1854
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
1855
        )
1856
        if not export_action.traceability_index.can_edit_document(document):
1857
            raise HTTPException(
1858
                status_code=403,
1859
                detail="Editing is disabled for autogenerated content.",
1860
            )
1861
 
1862
        form_object: DocumentConfigFormObject = (
1863
            DocumentConfigFormObject.create_from_request(
1864
                document_mid=document_mid,
1865
                request_form_data=request_form_data,
1866
            )
1867
        )
1868
        is_block_action = action in ("delete", "reorder")
1869
        active_metadata_field = None
1870
        active_metadata_index = -1
1871
        active_field_is_new = False
1872
        if not is_block_action:
1873
            if active_field_name not in ("name", "value", "new"):
1874
                return HTMLResponse(
1875
                    content=(
1876
                        "Invalid active custom metadata field name: "
1877
                        f"{active_field_name}"
1878
                    ),
1879
                    status_code=400,
1880
                )
1881
            active_metadata_field = next(
1882
                (
1883
                    metadata_field
1884
                    for metadata_field in form_object.custom_metadata_fields
1885
                    if metadata_field.field_mid == active_form_key
1886
                ),
1887
                None,
1888
            )
1889
            if active_metadata_field is None:
1890
                return HTMLResponse(
1891
                    content=(
1892
                        "Unknown active custom metadata form key: "
1893
                        f"{active_form_key}"
1894
                    ),
1895
                    status_code=400,
1896
                )
1897
            active_metadata_index = form_object.custom_metadata_fields.index(
1898
                active_metadata_field
1899
            )
1900
            active_field_is_new = active_form_key.startswith("new_custom_meta_")
1901
            if (
1902
                active_field_is_new
1903
                and len(active_metadata_field.field_name) == 0
1904
                and len(active_metadata_field.field_value) == 0
1905
            ):
1906
                # A fully empty Add row is not metadata. Skip it without running
1907
                # the transform or writing the document; partially filled rows
1908
                # continue through normal validation.
1909
                output = env().render_template_as_markup(
1910
                    "actions/table/update_document_custom_meta/stream_skip_empty_new.jinja.html",
1911
                    doc_mid=document_mid,
1912
                )
1913
                return HTMLResponse(
1914
                    content=output,
1915
                    status_code=200,
1916
                    headers={"Content-Type": "text/vnd.turbo-stream.html"},
1917
                )
1918
        try:
1919
            update_command = UpdateDocumentConfigTransform(
1920
                form_object=form_object,
1921
                document=document,
1922
                traceability_index=export_action.traceability_index,
1923
            )
1924
            update_command.perform()
1925
        except MultipleValidationError as validation_error:
1926
            if is_block_action:
1927
                return HTMLResponse(
1928
                    content="\n".join(
1929
                        error
1930
                        for errors in validation_error.errors.values()
1931
                        for error in errors
1932
                    ),
1933
                    status_code=422,
1934
                )
1935
            for error_key, errors in validation_error.errors.items():
1936
                for error in errors:
1937
                    form_object.add_error(error_key, error)
1938
            assert active_metadata_field is not None
1939
            active_field_errors = form_object.get_errors(
1940
                f"METADATA[{active_form_key}]"
1941
            )
1942
            name_errors = [
1943
                error
1944
                for error in active_field_errors
1945
                if error.startswith("Key ")
1946
            ]
1947
            value_errors = [
1948
                error
1949
                for error in active_field_errors
1950
                if error.startswith("Value ")
1951
            ]
1952
            if active_field_is_new:
1953
                output = env().render_template_as_markup(
1954
                    "actions/table/get_document_custom_meta_new_inline/stream_inline_form.jinja.html",
1955
                    form_key=active_form_key,
1956
                    field_name=active_metadata_field.field_name,
1957
                    field_value=active_metadata_field.field_value,
1958
                    errors=active_field_errors,
1959
                    name_errors=name_errors,
1960
                    value_errors=value_errors,
1961
                )
1962
                return HTMLResponse(
1963
                    content=output,
1964
                    status_code=422,
1965
                    headers={"Content-Type": "text/vnd.turbo-stream.html"},
1966
                )
1967
            output = env().render_template_as_markup(
1968
                "actions/table/get_document_custom_meta_inline/stream_inline_form.jinja.html",
1969
                form_key=active_form_key,
1970
                field_name=active_field_name,
1971
                field_label=active_metadata_field.field_name,
1972
                field_value=active_metadata_field.field_value,
1973
                errors=active_field_errors,
1974
                name_errors=name_errors,
1975
                value_errors=value_errors,
1976
            )
1977
            return HTMLResponse(
1978
                content=output,
1979
                status_code=422,
1980
                headers={"Content-Type": "text/vnd.turbo-stream.html"},
1981
            )
1982
 
1983
        write_document_to_file(document)
1984
        export_action.traceability_index.update_last_updated()
1985
 
1986
        assert document.meta is not None
1987
        link_renderer = LinkRenderer(
1988
            root_path=document.meta.get_root_path_prefix(),
1989
            static_path=project_config.dir_for_sdoc_assets,
1990
        )
1991
        markup_renderer = MarkupRenderer.create(
1992
            markup=document.config.get_markup(),
1993
            traceability_index=export_action.traceability_index,
1994
            link_renderer=link_renderer,
1995
            html_templates=html_generator.html_templates,
1996
            config=project_config,
1997
            context_document=document,
1998
        )
1999
        view_object = DocumentScreenViewObject(
2000
            document_type=DocumentType.DOCUMENT,
2001
            document=document,
2002
            traceability_index=export_action.traceability_index,
2003
            project_config=project_config,
2004
            link_renderer=link_renderer,
2005
            markup_renderer=markup_renderer,
2006
            jinja_environment=env(),
2007
            git_client=html_generator.git_client,
2008
        )
2009
        if is_block_action:
2010
            stream_template = (
2011
                "actions/table/update_document_custom_meta/"
2012
                f"stream_{action}.jinja.html"
2013
            )
2014
            output = env().render_template_as_markup(
2015
                stream_template,
2016
                doc_mid=document_mid,
2017
                document_config=document.config,
2018
                view_object=view_object,
2019
            )
2020
            return HTMLResponse(
2021
                content=output,
2022
                status_code=200,
2023
                headers={"Content-Type": "text/vnd.turbo-stream.html"},
2024
            )
2025
        assert active_metadata_field is not None
2026
        if active_field_is_new:
2027
            # New rows use a distinct transport key while unsaved. Once saved,
2028
            # normalize it to the positional key used by existing display rows.
2029
            form_key = f"custom_meta_{active_metadata_index}"
2030
            output = env().render_template_as_markup(
2031
                "actions/table/update_document_custom_meta/stream_add.jinja.html",
2032
                doc_mid=document_mid,
2033
                field_content=view_object.render_metadata_value(
2034
                    active_metadata_field.field_value
2035
                ),
2036
                field_label=active_metadata_field.field_name,
2037
                field_value=active_metadata_field.field_value,
2038
                form_key=form_key,
2039
                view_object=view_object,
2040
            )
2041
            return HTMLResponse(
2042
                content=output,
2043
                status_code=200,
2044
                headers={"Content-Type": "text/vnd.turbo-stream.html"},
2045
            )
2046
        output = env().render_template_as_markup(
2047
            "actions/table/update_document_custom_meta/stream_update.jinja.html",
2048
            active_field_name=active_field_name,
2049
            field_content=view_object.render_metadata_value(
2050
                active_metadata_field.field_value
2051
            ),
2052
            field_label=active_metadata_field.field_name,
2053
            field_value=active_metadata_field.field_value,
2054
            form_key=active_form_key,
2055
        )
2056
        return HTMLResponse(
2057
            content=output,
2058
            status_code=200,
2059
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
2060
        )
2061
 
2062
    @write_router.post(
2063
        "/actions/table/update_node_field_multiline", response_class=Response
2064
    )
2065
    def table__update_node_field_multiline(
2066
        request_form_data: FormData = Depends(parse_form_data),
2067
    ) -> Response:
2068
        request_dict = dict(request_form_data)
2069
        node_mid_str: str = request_dict["node_mid"]
2070
        field_name: str = request_dict["field_name"]
2071
        field_value: str = request_dict.get("field_value", "")
2072
 
2073
        node: SDocNode = export_action.traceability_index.get_node_by_mid(
2074
            MID(node_mid_str)
2075
        )
2076
        document = assert_cast(node.get_document(), SDocDocument)
2077
        assert document.grammar is not None
2078
        grammar: DocumentGrammar = document.grammar
2079
        element: GrammarElement = grammar.elements_by_type[node.node_type]
2080
 
2081
        if field_name not in element.fields_map:
2082
            return HTMLResponse(
2083
                content=f"Unknown field: {field_name}", status_code=400
2084
            )
2085
        if not element.is_field_multiline(field_name):
2086
            return HTMLResponse(
2087
                content=f"Field {field_name} is not multiline", status_code=400
2088
            )
2089
 
2090
        sanitized_value: str = sanitize_html_form_field(
2091
            field_value, multiline=True
2092
        )
2093
 
2094
        revision: int = revisions[node_mid_str]
2095
        form_object: RequirementFormObject = (
2096
            RequirementFormObject.create_from_requirement(
2097
                requirement=node,
2098
                revision=revision,
2099
                context_document_mid=document.reserved_mid.get_string_value(),
2100
            )
2101
        )
2102
 
2103
        if field_name in form_object.fields:
2104
            form_object.fields[field_name][0].field_value = sanitized_value
2105
 
2106
        form_object.validate(
2107
            traceability_index=export_action.traceability_index,
2108
            context_document=document,
2109
            config=project_config,
2110
            existing_revision=revision,
2111
        )
2112
        if form_object.any_errors():
2113
            # WIP: error text is collected for future inline error display.
2114
            field_errors: List[str] = []
2115
            for error_list in form_object.errors.values():
2116
                field_errors.extend(error_list)
2117
            return HTMLResponse(
2118
                content="\n".join(field_errors),
2119
                status_code=422,
2120
            )
2121
 
2122
        update_command = CreateOrUpdateNodeCommand(
2123
            form_object=form_object,
2124
            node_info=UpdateNodeInfo(node_to_update=node),
2125
            traceability_index=export_action.traceability_index,
2126
            project_config=project_config,
2127
        )
2128
        update_command.perform()
2129
        write_document_to_file(document)
2130
        revisions[node_mid_str] += 1
2131
 
2132
        assert document.meta is not None
2133
        link_renderer = LinkRenderer(
2134
            root_path=document.meta.get_root_path_prefix(),
2135
            static_path=project_config.dir_for_sdoc_assets,
2136
        )
2137
        markup_renderer = MarkupRenderer.create(
2138
            markup=document.config.get_markup(),
2139
            traceability_index=export_action.traceability_index,
2140
            link_renderer=link_renderer,
2141
            html_templates=html_generator.html_templates,
2142
            config=project_config,
2143
            context_document=document,
2144
        )
2145
 
2146
        if field_name == element.content_field[0]:
2147
            rendered_content = (
2148
                markup_renderer.render_node_statement(
2149
                    DocumentType.DOCUMENT, node
2150
                )
2151
                if node.has_reserved_statement()
2152
                else ""
2153
            )
2154
        elif field_name == "RATIONALE":
2155
            rendered_content = (
2156
                markup_renderer.render_node_rationale(
2157
                    DocumentType.DOCUMENT, node
2158
                )
2159
                if node.rationale
2160
                else ""
2161
            )
2162
        elif field_name in node.ordered_fields_lookup:
2163
            node_field = node.ordered_fields_lookup[field_name][0]
2164
            rendered_content = markup_renderer.render_node_field(
2165
                DocumentType.DOCUMENT, node_field
2166
            )
2167
        else:
2168
            rendered_content = ""
2169
 
2170
        output = env().render_template_as_markup(
2171
            "actions/table/update_node_field_multiline/stream_update.jinja.html",
2172
            node_mid=node_mid_str,
2173
            field_name=field_name,
2174
            rendered_content=rendered_content,
2175
        )
2176
        return HTMLResponse(
2177
            content=output,
2178
            status_code=200,
2179
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
2180
        )
2181
 
2182
    @write_router.post(
2183
        "/actions/table/update_node_comments", response_class=Response
2184
    )
2185
    def table__update_node_comments(
2186
        request_form_data: FormData = Depends(parse_form_data),
2187
    ) -> Response:
2188
        request_dict = dict(request_form_data)
2189
        node_mid_str: str = request_dict["requirement_mid"]
2190
        node: SDocNode = export_action.traceability_index.get_node_by_mid(
2191
            MID(node_mid_str)
2192
        )
2193
        document = assert_cast(node.get_document(), SDocDocument)
2194
 
2195
        form_object: RequirementFormObject = (
2196
            RequirementFormObject.create_from_request(
2197
                is_new=False,
2198
                requirement_mid=node_mid_str,
2199
                request_form_data=request_form_data,
2200
                document=document,
2201
                existing_requirement_uid=node.reserved_uid,
2202
            )
2203
        )
2204
        existing_revision: int = revisions[node_mid_str]
2205
 
2206
        form_object.validate(
2207
            traceability_index=export_action.traceability_index,
2208
            context_document=document,
2209
            config=project_config,
2210
            existing_revision=existing_revision,
2211
        )
2212
        if form_object.any_errors():
2213
            error_output = env().render_template_as_markup(
2214
                "actions/table/get_node_comments_inline/stream_inline_form.jinja.html",
2215
                form_object=form_object,
2216
            )
2217
            return HTMLResponse(
2218
                content=error_output,
2219
                status_code=422,
2220
                headers={"Content-Type": "text/vnd.turbo-stream.html"},
2221
            )
2222
 
2223
        update_command = CreateOrUpdateNodeCommand(
2224
            form_object=form_object,
2225
            node_info=UpdateNodeInfo(node_to_update=node),
2226
            traceability_index=export_action.traceability_index,
2227
            project_config=project_config,
2228
        )
2229
        update_command.perform()
2230
        write_document_to_file(document)
2231
        revisions[node_mid_str] += 1
2232
 
2233
        assert document.meta is not None
2234
        link_renderer = LinkRenderer(
2235
            root_path=document.meta.get_root_path_prefix(),
2236
            static_path=project_config.dir_for_sdoc_assets,
2237
        )
2238
        markup_renderer = MarkupRenderer.create(
2239
            markup=document.config.get_markup(),
2240
            traceability_index=export_action.traceability_index,
2241
            link_renderer=link_renderer,
2242
            html_templates=html_generator.html_templates,
2243
            config=project_config,
2244
            context_document=document,
2245
        )
2246
 
2247
        rendered_comments: List[str] = []
2248
        if "COMMENT" in node.ordered_fields_lookup:
2249
            for comment_field_ in node.ordered_fields_lookup["COMMENT"]:
2250
                rendered_comments.append(
2251
                    markup_renderer.render_node_field(
2252
                        DocumentType.DOCUMENT, comment_field_
2253
                    )
2254
                )
2255
 
2256
        output = env().render_template_as_markup(
2257
            "actions/table/update_node_comments/stream_update.jinja.html",
2258
            node_mid=node_mid_str,
2259
            rendered_comments=rendered_comments,
2260
        )
2261
        return HTMLResponse(
2262
            content=output,
2263
            status_code=200,
2264
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
2265
        )
2266
 
2267
    @write_router.post(
2268
        "/actions/table/update_node_relations", response_class=Response
2269
    )
2270
    def table__update_node_relations(
2271
        request_form_data: FormData = Depends(parse_form_data),
2272
    ) -> Response:
2273
        request_dict = dict(request_form_data)
2274
        node_mid_str: str = request_dict["requirement_mid"]
2275
        node: SDocNode = export_action.traceability_index.get_node_by_mid(
2276
            MID(node_mid_str)
2277
        )
2278
        document = assert_cast(node.get_document(), SDocDocument)
2279
 
2280
        form_object: RequirementFormObject = (
2281
            RequirementFormObject.create_from_request(
2282
                is_new=False,
2283
                requirement_mid=node_mid_str,
2284
                request_form_data=request_form_data,
2285
                document=document,
2286
                existing_requirement_uid=node.reserved_uid,
2287
            )
2288
        )
2289
        existing_revision: int = revisions[node_mid_str]
2290
 
2291
        context_document: SDocDocument = (
2292
            export_action.traceability_index.get_node_by_mid(
2293
                MID(form_object.context_document_mid)
2294
            )
2295
        )
2296
 
2297
        form_object.validate(
2298
            traceability_index=export_action.traceability_index,
2299
            context_document=document,
2300
            config=project_config,
2301
            existing_revision=existing_revision,
2302
        )
2303
        if form_object.any_errors():
2304
            assert document.meta is not None
2305
            error_link_renderer = LinkRenderer(
2306
                root_path=document.meta.get_root_path_prefix(),
2307
                static_path=project_config.dir_for_sdoc_assets,
2308
            )
2309
            own_relation_uids = {
2310
                r.ref_uid
2311
                for r in node.relations
2312
                if hasattr(r, "ref_uid") and r.ref_uid
2313
            }
2314
            traceability_linked_nodes = (
2315
                export_action.traceability_index.get_parent_requirements(node)
2316
                + export_action.traceability_index.get_children_requirements(
2317
                    node
2318
                )
2319
            )
2320
            derived_nodes = [
2321
                req
2322
                for req in traceability_linked_nodes
2323
                if req.reserved_uid not in own_relation_uids
2324
            ]
2325
            view_object_stub = types.SimpleNamespace(
2326
                render_node_link=lambda req: (
2327
                    error_link_renderer.render_node_link(
2328
                        req, document, DocumentType.DOCUMENT
2329
                    )
2330
                ),
2331
            )
2332
            error_output = env().render_template_as_markup(
2333
                "actions/table/get_node_relations_inline/stream_inline_form.jinja.html",
2334
                form_object=form_object,
2335
                derived_nodes=derived_nodes,
2336
                view_object=view_object_stub,
2337
            )
2338
            return HTMLResponse(
2339
                content=error_output,
2340
                status_code=422,
2341
                headers={"Content-Type": "text/vnd.turbo-stream.html"},
2342
            )
2343
 
2344
        old_related_uids = {
2345
            r.ref_uid
2346
            for r in node.relations
2347
            if hasattr(r, "ref_uid") and r.ref_uid
2348
        }
2349
 
2350
        update_command = CreateOrUpdateNodeCommand(
2351
            form_object=form_object,
2352
            node_info=UpdateNodeInfo(node_to_update=node),
2353
            traceability_index=export_action.traceability_index,
2354
            project_config=project_config,
2355
        )
2356
        update_command.perform()
2357
        write_document_to_file(document)
2358
        revisions[node_mid_str] += 1
2359
 
2360
        new_related_uids = {
2361
            r.ref_uid
2362
            for r in node.relations
2363
            if hasattr(r, "ref_uid") and r.ref_uid
2364
        }
2365
        # Linking/unlinking this node also changes the computed Parent/Child
2366
        # relations shown on the other side of the link, so those rows need
2367
        # their RELATIONS cell refreshed too.
2368
        affected_related_nodes = [
2369
            related_node
2370
            for uid in old_related_uids | new_related_uids
2371
            if isinstance(
2372
                related_node
2373
                := export_action.traceability_index.get_node_by_uid_weak(uid),
2374
                SDocNode,
2375
            )
2376
            and related_node.reserved_mid != node.reserved_mid
2377
        ]
2378
 
2379
        assert document.meta is not None
2380
        link_renderer = LinkRenderer(
2381
            root_path=document.meta.get_root_path_prefix(),
2382
            static_path=project_config.dir_for_sdoc_assets,
2383
        )
2384
 
2385
        view_object_stub = types.SimpleNamespace(
2386
            traceability_index=export_action.traceability_index,
2387
            project_config=project_config,
2388
            link_renderer=link_renderer,
2389
            render_node_link=lambda req: link_renderer.render_node_link(
2390
                req, context_document, DocumentType.DOCUMENT
2391
            ),
2392
        )
2393
 
2394
        output = env().render_template_as_markup(
2395
            "actions/table/update_node_relations/stream_update.jinja.html",
2396
            node_mid=node_mid_str,
2397
            requirement=node,
2398
            affected_related_nodes=affected_related_nodes,
2399
            view_object=view_object_stub,
2400
        )
2401
        return HTMLResponse(
2402
            content=output,
2403
            status_code=200,
2404
            headers={"Content-Type": "text/vnd.turbo-stream.html"},
2405
        )
2406
 
2407
    @read_router.get(
2408
        "/actions/document/cancel_new_requirement", response_class=Response
2409
    )
2410
    def cancel_new_requirement(requirement_mid: str) -> Response:
2411
        output = env().render_template_as_markup(
2412
            "actions/"
2413
            "document/"
2414
            "create_requirement/"
2415
            "stream_cancel_new_requirement.jinja.html",
2416
            requirement_mid=requirement_mid,
2417
        )
2418
        return HTMLResponse(
2419
            content=output,
2420
            status_code=200,
2421
            headers={
2422
                "Content-Type": "text/vnd.turbo-stream.html",
2423
            },
2424
        )
2425
 
2426
    @read_router.get(
2427
        "/actions/document/cancel_edit_requirement", response_class=Response
2428
    )
2429
    def cancel_edit_requirement(requirement_mid: str) -> Response:
2430
        """
2431
        @relation(SDOC-SRS-55, scope=function)
2432
        """
2433
 
2434
        assert isinstance(requirement_mid, str) and len(requirement_mid) > 0, (
2435
            f"{requirement_mid}"
2436
        )
2437
        requirement: SDocNode = (
2438
            export_action.traceability_index.get_node_by_mid(
2439
                MID(requirement_mid)
2440
            )
2441
        )
2442
        document: SDocDocument = assert_cast(
2443
            requirement.get_document(), SDocDocument
2444
        )
2445
        assert document.meta is not None
2446
        link_renderer = LinkRenderer(
2447
            root_path=document.meta.get_root_path_prefix(),
2448
            static_path=project_config.dir_for_sdoc_assets,
2449
        )
2450
        markup_renderer = MarkupRenderer.create(
2451
            markup=document.config.get_markup(),
2452
            traceability_index=export_action.traceability_index,
2453
            link_renderer=link_renderer,
2454
            html_templates=html_generator.html_templates,
2455
            config=project_config,
2456
            context_document=document,
2457
        )
2458
        view_object = DocumentScreenViewObject(
2459
            document_type=DocumentType.DOCUMENT,
2460
            document=document,
2461
            traceability_index=export_action.traceability_index,
2462
            project_config=project_config,
2463
            link_renderer=link_renderer,
2464
            markup_renderer=markup_renderer,
2465
            jinja_environment=env(),
2466
            git_client=html_generator.git_client,
2467
        )
2468
        return HTMLResponse(
2469
            content=view_object.render_updated_nodes_and_toc(
2470
                [requirement], node_updated=False
2471
            ),
2472
            headers={
2473
                "Content-Type": "text/vnd.turbo-stream.html",
2474
            },
2475
        )
2476
 
2477
    @write_router.delete(
2478
        "/actions/document/delete_requirement",
2479
        response_class=Response,
2480
    )
2481
    def delete_requirement(
2482
        node_id: str, context_document_mid: str, confirmed: bool = False
2483
    ) -> Response:
2484
        requirement: SDocNode = (
2485
            export_action.traceability_index.get_node_by_mid(MID(node_id))
2486
        )
2487
        if not export_action.traceability_index.can_delete_node(requirement):
2488
            raise HTTPException(
2489
                status_code=403,
2490
                detail="Deleting is disabled for autogenerated content.",
2491
            )
2492
 
2493
        document: SDocDocument = assert_cast(
2494
            requirement.get_document(), SDocDocument
2495
        )
2496
        if not confirmed:
2497
            errors: List[str]
2498
            try:
2499
                delete_command = DeleteRequirementCommand(
2500
                    requirement=requirement,
2501
                    traceability_index=export_action.traceability_index,
2502
                )
2503
                delete_command.validate()
2504
                errors = []
2505
            except MultipleValidationErrorAsList as error_:
2506
                errors = error_.errors
2507
 
2508
            output = env().render_template_as_markup(
2509
                "actions/document/delete_requirement/"
2510
                "stream_confirm_delete_requirement.jinja",
2511
                requirement_mid=node_id,
2512
                context_document_mid=context_document_mid,
2513
                errors=errors,
2514
            )
2515
            return HTMLResponse(
2516
                content=output,
2517
                status_code=200 if len(errors) == 0 else 422,
2518
                headers={
2519
                    "Content-Type": "text/vnd.turbo-stream.html",
2520
                },
2521
            )
2522
 
2523
        try:
2524
            delete_command = DeleteRequirementCommand(
2525
                requirement=requirement,
2526
                traceability_index=export_action.traceability_index,
2527
            )
2528
            delete_command.perform()
2529
        except MultipleValidationError:
2530
            return HTMLResponse(
2531
                content="",
2532
                status_code=422,
2533
                headers={
2534
                    "Content-Type": "text/vnd.turbo-stream.html",
2535
                },
2536
            )
2537
 
2538
        # Saving new content to .SDoc file.
2539
        write_document_to_file(document)
2540
 
2541
        context_document: SDocDocument = (
2542
            export_action.traceability_index.get_node_by_mid(
2543
                MID(context_document_mid)
2544
            )
2545
        )
2546
 
2547
        # Rendering back the Turbo template.
2548
        assert document.meta is not None
2549
        link_renderer = LinkRenderer(
2550
            root_path=document.meta.get_root_path_prefix(),
2551
            static_path=project_config.dir_for_sdoc_assets,
2552
        )
2553
        markup_renderer = MarkupRenderer.create(
2554
            markup=document.config.get_markup(),
2555
            traceability_index=export_action.traceability_index,
2556
            link_renderer=link_renderer,
2557
            html_templates=html_generator.html_templates,
2558
            config=project_config,
2559
            context_document=document,
2560
        )
2561
        view_object: DocumentScreenViewObject = DocumentScreenViewObject(
2562
            document_type=DocumentType.DOCUMENT,
2563
            document=context_document,
2564
            traceability_index=export_action.traceability_index,
2565
            project_config=project_config,
2566
            link_renderer=link_renderer,
2567
            markup_renderer=markup_renderer,
2568
            jinja_environment=env(),
2569
            git_client=html_generator.git_client,
2570
        )
2571
        output = env().render_template_as_markup(
2572
            "actions/document/delete_requirement/"
2573
            "stream_delete_requirement.jinja.html",
2574
            view_object=view_object,
2575
        )
2576
 
2577
        output += env().render_template_as_markup(
2578
            "actions/document/_shared/stream_updated_toc.jinja.html",
2579
            view_object=view_object,
2580
        )
2581
 
2582
        output += env().render_template_as_markup(
2583
            "actions/document/_shared/stream_updated_viewtype_menu.jinja.html",
2584
            view_object=view_object,
2585
        )
2586
 
2587
        return HTMLResponse(
2588
            content=output,
2589
            status_code=200,
2590
            headers={
2591
                "Content-Type": "text/vnd.turbo-stream.html",
2592
            },
2593
        )
2594
 
2595
    @write_router.post("/actions/document/move_node", response_class=Response)
2596
    def move_node(
2597
        request_form_data: FormData = Depends(parse_form_data),
2598
    ) -> Response:
2599
        """
2600
        @relation(SDOC-SRS-92, scope=function)
2601
        """
2602
 
2603
        request_dict: Dict[str, str] = dict(request_form_data)
2604
        moved_node_mid: str = request_dict["moved_node_mid"]
2605
        target_mid: str = request_dict["target_mid"]
2606
        whereto: str = request_dict["whereto"]
2607
 
2608
        assert export_action.traceability_index is not None
2609
 
2610
        moved_node = export_action.traceability_index.get_node_by_mid(
2611
            MID(moved_node_mid)
2612
        )
2613
        document: SDocDocument = assert_cast(
2614
            moved_node.get_document(), SDocDocument
2615
        )
2616
        target_node = export_action.traceability_index.get_node_by_mid(
2617
            MID(target_mid)
2618
        )
2619
        moved_sdoc_node = assert_cast(moved_node, SDocNode)
2620
        if not export_action.traceability_index.can_move_node_to(
2621
            moved_sdoc_node, target_node, whereto
2622
        ):
2623
            raise HTTPException(
2624
                status_code=403,
2625
                detail="Moving is disabled for autogenerated content.",
2626
            )
2627
 
2628
        current_parent_node = moved_node.parent
2629
 
2630
        # Currently UI allows a child-like drag-and-drop on a leaf (non-composite) node.
2631
        # In that case, we make it add a node **after** the target node
2632
        # (not as its child because that's not possible).
2633
        if (
2634
            whereto == NodeCreationOrder.CHILD
2635
            and isinstance(target_node, SDocNode)
2636
            and not target_node.is_composite
2637
        ):
2638
            whereto = NodeCreationOrder.AFTER
2639
 
2640
        if whereto == NodeCreationOrder.CHILD:
2641
            # Disconnect the moved_node from its parent.
2642
            current_parent_node.section_contents.remove(moved_node)
2643
            # Append to the end of child list.
2644
            target_node.section_contents.append(moved_node)
2645
            moved_node.parent = target_node
2646
        elif whereto == NodeCreationOrder.BEFORE:
2647
            # Disconnect the moved_node from its parent.
2648
            current_parent_node.section_contents.remove(moved_node)
2649
            # Append before.
2650
            insert_to_idx = target_node.parent.section_contents.index(
2651
                target_node
2652
            )
2653
            target_node.parent.section_contents.insert(
2654
                insert_to_idx, moved_node
2655
            )
2656
            moved_node.parent = target_node.parent
2657
        elif whereto == NodeCreationOrder.AFTER:
2658
            # Disconnect the moved_node from its parent.
2659
            current_parent_node.section_contents.remove(moved_node)
2660
            # Append after.
2661
            insert_to_idx = target_node.parent.section_contents.index(
2662
                target_node
2663
            )
2664
            target_node.parent.section_contents.insert(
2665
                insert_to_idx + 1, moved_node
2666
            )
2667
            moved_node.parent = target_node.parent
2668
        else:
2669
            raise NotImplementedError
2670
 
2671
        # Saving new content to .SDoc file.
2672
        write_document_to_file(document)
2673
 
2674
        # Update the index because other documents might reference this
2675
        # document's sections. These documents will be regenerated on demand,
2676
        # when they are opened next time.
2677
        export_action.traceability_index.update_last_updated()
2678
 
2679
        assert document.meta is not None
2680
        link_renderer = LinkRenderer(
2681
            root_path=document.meta.get_root_path_prefix(),
2682
            static_path=project_config.dir_for_sdoc_assets,
2683
        )
2684
        markup_renderer = MarkupRenderer.create(
2685
            markup=document.config.get_markup(),
2686
            traceability_index=export_action.traceability_index,
2687
            link_renderer=link_renderer,
2688
            html_templates=html_generator.html_templates,
2689
            config=project_config,
2690
            context_document=document,
2691
        )
2692
        view_object = DocumentScreenViewObject(
2693
            document_type=DocumentType.DOCUMENT,
2694
            document=document,
2695
            traceability_index=export_action.traceability_index,
2696
            project_config=project_config,
2697
            link_renderer=link_renderer,
2698
            markup_renderer=markup_renderer,
2699
            jinja_environment=env(),
2700
            git_client=html_generator.git_client,
2701
        )
2702
        return HTMLResponse(
2703
            content=view_object.render_update_document_content_with_moved_node(
2704
                moved_node
2705
            ),
2706
            headers={
2707
                "Content-Type": "text/vnd.turbo-stream.html",
2708
            },
2709
        )
2710
 
2711
    @read_router.get(
2712
        "/actions/project_index/new_document", response_class=Response
2713
    )
2714
    def get_new_document() -> Response:
2715
        """
2716
        @relation(SDOC-SRS-107, scope=function)
2717
        """
2718
 
2719
        output = env().render_template_as_markup(
2720
            "actions/project_index/stream_new_document.jinja.html",
2721
            error_object=ErrorObject(),
2722
            document_title="",
2723
            document_path="",
2724
            include_doc_paths=project_config.include_doc_paths,
2725
        )
2726
        return HTMLResponse(
2727
            content=output,
2728
            headers={
2729
                "Content-Type": "text/vnd.turbo-stream.html",
2730
            },
2731
        )
2732
 
2733
    @read_router.get(
2734
        "/actions/project_index/edit_project_title_form",
2735
        response_class=Response,
2736
    )
2737
    def get_edit_project_title_form() -> Response:
2738
        error_object = ErrorObject()
2739
        output = env().render_template_as_markup(
2740
            "actions/project_index/edit_project_title/"
2741
            "stream_form_edit_project_title.jinja.html",
2742
            error_object=error_object,
2743
            project_config=project_config,
2744
        )
2745
        return HTMLResponse(
2746
            content=output,
2747
            headers={
2748
                "Content-Type": "text/vnd.turbo-stream.html",
2749
            },
2750
        )
2751
 
2752
    @write_router.post(
2753
        "/actions/project_index/save_project_title", response_class=Response
2754
    )
2755
    def save_project_title(project_title: str = Form("")) -> Response:
2756
        error_object = ErrorObject()
2757
 
2758
        new_title = project_title.strip() if project_title is not None else ""
2759
        if len(new_title) == 0:
2760
            error_object.add_error(
2761
                "project_title", "Project title must not be empty."
2762
            )
2763
 
2764
        if error_object.any_errors():
2765
            output = env().render_template_as_markup(
2766
                "actions/project_index/edit_project_title/"
2767
                "stream_form_edit_project_title.jinja.html",
2768
                error_object=error_object,
2769
                project_config=project_config,
2770
                new_title=new_title,
2771
            )
2772
            return HTMLResponse(
2773
                content=output,
2774
                status_code=200,
2775
                headers={
2776
                    "Content-Type": "text/vnd.turbo-stream.html",
2777
                },
2778
            )
2779
 
2780
        # Try to persist the new title into the project configuration when available.
2781
        project_root = project_config.get_project_root_path()
2782
        config_toml_path: Optional[str] = None
2783
        config_py_path: Optional[str] = None
2784
 
2785
        if os.path.isdir(project_root):
2786
            # Prefer Python config when both exist.
2787
            candidate_py = os.path.join(project_root, "strictdoc_config.py")
2788
            candidate_toml = os.path.join(project_root, "strictdoc.toml")
2789
            if os.path.isfile(candidate_py):
2790
                config_py_path = candidate_py
2791
            elif os.path.isfile(candidate_toml):
2792
                config_toml_path = candidate_toml
2793
        else:
2794
            # project_root may point directly to a config file or to an
2795
            # input path next to the config files.
2796
            if project_root.endswith("strictdoc.toml"):
2797
                config_toml_path = project_root
2798
            elif project_root.endswith("strictdoc_config.py"):
2799
                config_py_path = project_root
2800
            else:
2801
                config_dir = os.path.dirname(project_root)
2802
                candidate_py = os.path.join(config_dir, "strictdoc_config.py")
2803
                candidate_toml = os.path.join(config_dir, "strictdoc.toml")
2804
                if os.path.isfile(candidate_py):
2805
                    config_py_path = candidate_py
2806
                elif os.path.isfile(candidate_toml):
2807
                    config_toml_path = candidate_toml
2808
 
2809
        # strictdoc.toml is not supported anymore.
2810
        if config_toml_path is not None:
2811
            error_object = ErrorObject()
2812
 
2813
            error_object.add_error(
2814
                "project_title",
2815
                "Renaming project title is not supported with TOML config files. Switch from strictdoc_config.toml to strictdoc_config.py and try again.",
2816
            )
2817
 
2818
            output = env().render_template_as_markup(
2819
                "actions/project_index/edit_project_title/"
2820
                "stream_form_edit_project_title.jinja.html",
2821
                error_object=error_object,
2822
                project_config=project_config,
2823
                new_title=new_title,
2824
            )
2825
            return HTMLResponse(
2826
                content=output,
2827
                status_code=400,
2828
                headers={
2829
                    "Content-Type": "text/vnd.turbo-stream.html",
2830
                },
2831
            )
2832
 
2833
        # Update strictdoc_config.py by editing its title using regex.
2834
        # The implementation is pretty hacky but should work for now.
2835
        if config_py_path is not None:
2836
            with open(config_py_path, encoding="utf8") as config_file:
2837
                config_text = config_file.read()
2838
 
2839
            pattern = re.compile(
2840
                r"(project_title\s*=\s*)([\"'])(.*?)([\"'])",
2841
                re.DOTALL,
2842
            )
2843
 
2844
            def _replace_title(match: re.Match[str]) -> str:
2845
                prefix = match.group(1)
2846
                quote = match.group(2)
2847
                escaped_title = new_title.replace(quote, "\\" + quote)
2848
                return f"{prefix}{quote}{escaped_title}{quote}"
2849
 
2850
            new_text, count = pattern.subn(_replace_title, config_text, count=1)
2851
 
2852
            if count > 0:
2853
                with open(config_py_path, "w", encoding="utf8") as config_file:
2854
                    config_file.write(new_text)
2855
            else:
2856
                error_object = ErrorObject()
2857
 
2858
                error_object.add_error(
2859
                    "project_title",
2860
                    (
2861
                        "Renaming project title is not supported when a title is "
2862
                        "not already configured to a previous value in"
2863
                        "strictdoc_config.py."
2864
                    ),
2865
                )
2866
 
2867
                output = env().render_template_as_markup(
2868
                    "actions/project_index/edit_project_title/"
2869
                    "stream_form_edit_project_title.jinja.html",
2870
                    error_object=error_object,
2871
                    project_config=project_config,
2872
                    new_title=new_title,
2873
                )
2874
                return HTMLResponse(
2875
                    content=output,
2876
                    status_code=400,
2877
                    headers={
2878
                        "Content-Type": "text/vnd.turbo-stream.html",
2879
                    },
2880
                )
2881
 
2882
        # Update in-memory project configuration after successful validation
2883
        # of where the title can be stored on disk.
2884
        project_config.project_title = new_title
2885
 
2886
        # This ensures that the cached project index HTML page is invalidated.
2887
        export_action.traceability_index.update_last_updated()
2888
 
2889
        # Return Turbo Streams to update the header title and close the modal.
2890
        output = env().render_template_as_markup(
2891
            "actions/project_index/edit_project_title/"
2892
            "stream_save_project_title.jinja.html",
2893
            project_config=project_config,
2894
        )
2895
        return HTMLResponse(
2896
            content=output,
2897
            status_code=200,
2898
            headers={
2899
                "Content-Type": "text/vnd.turbo-stream.html",
2900
            },
2901
        )
2902
 
2903
    @write_router.post(
2904
        "/actions/project_index/create_document", response_class=Response
2905
    )
2906
    def document_tree__create_document(
2907
        document_title: str = Form(""),
2908
        document_path: str = Form(""),
2909
    ) -> Response:
2910
        """
2911
        @relation(SDOC-SRS-107, scope=function)
2912
        """
2913
 
2914
        error_object = ErrorObject()
2915
        if document_title is None or len(document_title) == 0:
2916
            error_object.add_error(
2917
                "document_title", "Document title must not be empty."
2918
            )
2919
        if document_path is None or len(document_path) == 0:
2920
            error_object.add_error(
2921
                "document_path", "Document path must not be empty."
2922
            )
2923
        else:
2924
            document_path = document_path.strip().lstrip("/")
2925
            if not is_safe_alphanumeric_string(document_path):
2926
                error_object.add_error(
2927
                    "document_path",
2928
                    (
2929
                        "Document path must be relative and only contain "
2930
                        "slashes, alphanumeric characters, "
2931
                        "and underscore symbols."
2932
                    ),
2933
                )
2934
 
2935
        if project_config.include_doc_paths is not None:
2936
            path_filter_includes = PathFilter(
2937
                project_config.include_doc_paths, positive_or_negative=True
2938
            )
2939
            if not path_filter_includes.match(document_path):
2940
                error_object.add_error(
2941
                    "document_path",
2942
                    (
2943
                        "Document path is not a valid path according to "
2944
                        "the project config's setting 'include_doc_paths': "
2945
                        f"{project_config.include_doc_paths}."
2946
                    ),
2947
                )
2948
        if project_config.exclude_doc_paths is not None:
2949
            path_filter_excludes = PathFilter(
2950
                project_config.exclude_doc_paths, positive_or_negative=False
2951
            )
2952
            if path_filter_excludes.match(document_path):
2953
                error_object.add_error(
2954
                    "document_path",
2955
                    (
2956
                        "Document path is not a valid path according to "
2957
                        "the project config's setting 'exclude_doc_paths': "
2958
                        f"{project_config.exclude_doc_paths}."
2959
                    ),
2960
                )
2961
 
2962
        if error_object.any_errors():
2963
            output = env().render_template_as_markup(
2964
                "actions/project_index/stream_new_document.jinja.html",
2965
                error_object=error_object,
2966
                document_title=document_title
2967
                if document_title is not None
2968
                else "",
2969
                document_path=document_path
2970
                if document_path is not None
2971
                else "",
2972
                include_doc_paths=project_config.include_doc_paths,
2973
            )
2974
            return HTMLResponse(
2975
                content=output,
2976
                status_code=200,
2977
                headers={
2978
                    "Content-Type": "text/vnd.turbo-stream.html",
2979
                },
2980
            )
2981
 
2982
        if not document_path.endswith(".sdoc"):
2983
            document_path = document_path + ".sdoc"
2984
 
2985
        assert isinstance(project_config.input_paths, list)
2986
        full_input_path = os.path.abspath(project_config.input_paths[0])
2987
        file_tree_mount_folder = os.path.basename(
2988
            os.path.dirname(full_input_path)
2989
        )
2990
        doc_full_path = os.path.join(full_input_path, document_path)
2991
        doc_full_path_dir = os.path.dirname(doc_full_path)
2992
        document_file_name = os.path.basename(doc_full_path)
2993
        input_doc_dir_rel_path = os.path.dirname(document_path)
2994
        input_doc_assets_dir_rel_path = (
2995
            "/".join(
2996
                (
2997
                    file_tree_mount_folder,
2998
                    input_doc_dir_rel_path,
2999
                    "_assets",
3000
                )
3001
            )
3002
            if len(input_doc_dir_rel_path) > 0
3003
            else "/".join((file_tree_mount_folder, "_assets"))
3004
        )
3005
 
3006
        Path(doc_full_path_dir).mkdir(parents=True, exist_ok=True)
3007
        document = SDocDocument(
3008
            mid=None,
3009
            title=document_title,
3010
            config=None,
3011
            view=None,
3012
            grammar=DocumentGrammar.create_default(parent=None),
3013
            section_contents=[],
3014
        )
3015
        # FIXME: Fill in the document meta correctly.
3016
        document.meta = DocumentMeta(
3017
            level=0,
3018
            file_tree_mount_folder="NOT_RELEVANT",
3019
            document_filename=document_file_name,
3020
            document_filename_base="NOT_RELEVANT",
3021
            input_doc_full_path=doc_full_path,
3022
            input_doc_rel_path=SDocRelativePath(document_path),
3023
            input_doc_dir_rel_path=SDocRelativePath(input_doc_dir_rel_path),
3024
            input_doc_assets_dir_rel_path=SDocRelativePath(
3025
                input_doc_assets_dir_rel_path
3026
            ),
3027
            output_document_dir_full_path="NOT_RELEVANT",
3028
            output_document_dir_rel_path=SDocRelativePath("FIXME"),
3029
        )
3030
 
3031
        write_document_to_file(document)
3032
 
3033
        export_action.build_index()
3034
        export_action.export()
3035
 
3036
        view_object = ProjectTreeViewObject(
3037
            traceability_index=export_action.traceability_index,
3038
            project_config=project_config,
3039
        )
3040
        output = env().render_template_as_markup(
3041
            "actions/project_index/stream_create_document.jinja.html",
3042
            view_object=view_object,
3043
        )
3044
        return HTMLResponse(
3045
            content=output,
3046
            status_code=200,
3047
            headers={
3048
                "Content-Type": "text/vnd.turbo-stream.html",
3049
            },
3050
        )
3051
 
3052
    @write_router.delete(
3053
        "/actions/document/delete_document",
3054
        response_class=Response,
3055
    )
3056
    def delete_document(document_mid: str, confirmed: bool = False) -> Response:
3057
        """
3058
        Delete an entire SDOC document from the project.
3059
 
3060
        This endpoint is intentionally simple: it removes the underlying
3061
        ``.sdoc`` file from disk, rebuilds the index and redirects back to the
3062
        project index screen. For now, it is up to the user to ensure that
3063
        no other documents depend on this one (for example via ``INCLUDE``).
3064
        """
3065
 
3066
        document: SDocDocument = assert_cast(
3067
            export_action.traceability_index.get_node_by_mid(MID(document_mid)),
3068
            SDocDocument,
3069
        )
3070
        if not export_action.traceability_index.can_delete_node(document):
3071
            raise HTTPException(
3072
                status_code=403,
3073
                detail="Deleting is disabled for autogenerated content.",
3074
            )
3075
 
3076
        assert document.meta is not None
3077
 
3078
        errors: List[str] = []
3079
        try:
3080
            export_action.traceability_index.validate_can_remove_document(
3081
                document
3082
            )
3083
        except MultipleValidationErrorAsList as error_:
3084
            errors = error_.errors
3085
 
3086
        if not confirmed:
3087
            output = env().render_template_as_markup(
3088
                "actions/document/delete_document/"
3089
                "stream_confirm_delete_document.jinja",
3090
                document_mid=document_mid,
3091
                errors=errors,
3092
            )
3093
            return HTMLResponse(
3094
                content=output,
3095
                status_code=200 if len(errors) == 0 else 422,
3096
                headers={
3097
                    "Content-Type": "text/vnd.turbo-stream.html",
3098
                },
3099
            )
3100
 
3101
        if len(errors) > 0:
3102
            output = env().render_template_as_markup(
3103
                "actions/document/delete_document/"
3104
                "stream_confirm_delete_document.jinja",
3105
                document_mid=document_mid,
3106
                errors=errors,
3107
            )
3108
            return HTMLResponse(
3109
                content=output,
3110
                status_code=422,
3111
                headers={
3112
                    "Content-Type": "text/vnd.turbo-stream.html",
3113
                },
3114
            )
3115
 
3116
        # Remove the underlying SDOC file.
3117
        path_to_document = document.meta.input_doc_full_path
3118
        try:
3119
            if os.path.exists(path_to_document):
3120
                os.remove(path_to_document)
3121
        except OSError:
3122
            # If the file cannot be removed, keep the project index intact and
3123
            # fall back to a normal redirect; the error can be inspected in
3124
            # server logs.
3125
            pass
3126
 
3127
        # Best-effort cleanup of generated HTML artifacts for this document.
3128
        # Not all of these files are guaranteed to exist (e.g. PDF export).
3129
        html_paths = [
3130
            document.meta.get_html_doc_path(),
3131
            document.meta.get_html_table_path(),
3132
            document.meta.get_html_traceability_path(),
3133
            document.meta.get_html_deep_traceability_path(),
3134
            document.meta.get_html_pdf_path(),
3135
        ]
3136
        for html_path in html_paths:
3137
            try:
3138
                if os.path.exists(html_path):
3139
                    os.remove(html_path)
3140
            except OSError:
3141
                # Ignore individual file deletion errors; remaining files can
3142
                # be cleaned up manually if necessary.
3143
                continue
3144
 
3145
        # Rebuild the project index so the removed document disappears from
3146
        # the project tree and related views.
3147
        export_action.build_index()
3148
        export_action.export()
3149
 
3150
        # Redirect back to the project index page.
3151
        return RedirectResponse("/", status_code=303)
3152
 
3153
    @read_router.get("/actions/document/new_comment", response_class=Response)
3154
    def document__add_comment(
3155
        requirement_mid: str,
3156
        document_mid: str,
3157
        context_document_mid: str,
3158
        element_type: str,
3159
        revision: str,
3160
    ) -> Response:
3161
        document: SDocDocument = (
3162
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3163
        )
3164
        assert document.grammar is not None
3165
        grammar: DocumentGrammar = document.grammar
3166
        # The data of the form object is ignored. What matters is the comment
3167
        # form data.
3168
        output = env().render_template_as_markup(
3169
            "actions/"
3170
            "document/"
3171
            "add_requirement_comment/"
3172
            "stream_add_requirement_comment.jinja.html",
3173
            requirement_mid=requirement_mid,
3174
            form_object=RequirementFormObject(
3175
                is_new=False,
3176
                element_type=element_type,
3177
                revision=int(revision),
3178
                requirement_mid=requirement_mid,
3179
                document_mid=document.reserved_mid,
3180
                context_document_mid=context_document_mid,
3181
                fields=[],
3182
                reference_fields=[],
3183
                existing_requirement_uid=None,
3184
                grammar=grammar,
3185
                relation_types=[],
3186
            ),
3187
            field=RequirementFormField(
3188
                field_mid=MID.create(),
3189
                field_name="COMMENT",
3190
                field_type=RequirementFormFieldType.MULTILINE,
3191
                field_value="",
3192
            ),
3193
        )
3194
        return HTMLResponse(
3195
            content=output,
3196
            status_code=200,
3197
            headers={
3198
                "Content-Type": "text/vnd.turbo-stream.html",
3199
            },
3200
        )
3201
 
3202
    @read_router.get("/actions/document/new_relation", response_class=Response)
3203
    def document__add_relation(
3204
        requirement_mid: str,
3205
        document_mid: str,
3206
        context_document_mid: str,
3207
        element_type: str,
3208
        revision: str,
3209
    ) -> Response:
3210
        document: SDocDocument = (
3211
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3212
        )
3213
        assert document.grammar is not None
3214
        grammar: DocumentGrammar = document.grammar
3215
 
3216
        element: GrammarElement = grammar.elements_by_type[element_type]
3217
        grammar_element_relations = element.get_relation_types()
3218
 
3219
        # The data of the form object is ignored. What matters is the relation
3220
        # form data.
3221
        output = env().render_template_as_markup(
3222
            "actions/"
3223
            "document/"
3224
            "add_requirement_relation/"
3225
            "stream_add_requirement_relation.jinja.html",
3226
            requirement_mid=requirement_mid,
3227
            form_object=RequirementFormObject(
3228
                is_new=False,
3229
                element_type=element_type,
3230
                revision=int(revision),
3231
                requirement_mid=requirement_mid,
3232
                document_mid=document_mid,
3233
                context_document_mid=context_document_mid,
3234
                fields=[],
3235
                reference_fields=[],
3236
                existing_requirement_uid=None,
3237
                grammar=grammar,
3238
                relation_types=grammar_element_relations,
3239
            ),
3240
            field=RequirementReferenceFormField(
3241
                field_mid=MID.create(),
3242
                field_type=RequirementReferenceFormField.FieldType.PARENT,
3243
                field_value="",
3244
                field_role="",
3245
                # Mark as new so that an empty UID is silently discarded on save.
3246
                is_new=True,
3247
            ),
3248
            relation_types=grammar_element_relations,
3249
        )
3250
        return HTMLResponse(
3251
            content=output,
3252
            status_code=200,
3253
            headers={
3254
                "Content-Type": "text/vnd.turbo-stream.html",
3255
            },
3256
        )
3257
 
3258
    @read_router.get("/actions/document/edit_config", response_class=Response)
3259
    def document__edit_config(document_mid: str) -> Response:
3260
        """
3261
        @relation(SDOC-SRS-57, scope=function)
3262
        """
3263
 
3264
        document: SDocDocument = (
3265
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3266
        )
3267
        if not export_action.traceability_index.can_edit_document(document):
3268
            raise HTTPException(
3269
                status_code=403,
3270
                detail="Editing is disabled for autogenerated content.",
3271
            )
3272
 
3273
        form_object = DocumentConfigFormObject.create_from_document(
3274
            document=document
3275
        )
3276
 
3277
        output = env().render_template_as_markup(
3278
            "actions/"
3279
            "document/"
3280
            "edit_document_config/"
3281
            "stream_edit_document_config.jinja.html",
3282
            form_object=form_object,
3283
            document=document,
3284
        )
3285
        return HTMLResponse(
3286
            content=output,
3287
            status_code=200,
3288
            headers={
3289
                "Content-Type": "text/vnd.turbo-stream.html",
3290
            },
3291
        )
3292
 
3293
    @read_router.get("/actions/document/new_metadata", response_class=Response)
3294
    def document__add_metadata(
3295
        document_mid: str,
3296
    ) -> Response:
3297
        document: SDocDocument = (
3298
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3299
        )
3300
        if not export_action.traceability_index.can_edit_document(document):
3301
            raise HTTPException(
3302
                status_code=403,
3303
                detail="Editing is disabled for autogenerated content.",
3304
            )
3305
 
3306
        assert document.grammar is not None
3307
 
3308
        form_object = DocumentConfigFormObject.create_from_document(
3309
            document=document
3310
        )
3311
 
3312
        output = env().render_template_as_markup(
3313
            "actions/"
3314
            "document/"
3315
            "add_document_metadata/"
3316
            "stream_add_document_metadata.jinja.html",
3317
            form_object=form_object,
3318
            field=DocumentMetadataFormField(
3319
                field_mid=MID.create(),
3320
                field_name="",
3321
                field_value="",
3322
            ),
3323
        )
3324
        return HTMLResponse(
3325
            content=output,
3326
            status_code=200,
3327
            headers={
3328
                "Content-Type": "text/vnd.turbo-stream.html",
3329
            },
3330
        )
3331
 
3332
    @read_router.get(
3333
        "/actions/document/edit_included_document", response_class=Response
3334
    )
3335
    def document__edit_included_document(
3336
        document_mid: str, context_document_mid: str
3337
    ) -> Response:
3338
        document: SDocDocument = (
3339
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3340
        )
3341
        form_object = IncludedDocumentFormObject.create_from_document(
3342
            document=document,
3343
            context_document_mid=context_document_mid,
3344
            jinja_environment=env(),
3345
        )
3346
        return HTMLResponse(
3347
            content=form_object.render_edit_form(),
3348
            status_code=200,
3349
            headers={
3350
                "Content-Type": "text/vnd.turbo-stream.html",
3351
            },
3352
        )
3353
 
3354
    @write_router.post("/actions/document/save_config", response_class=Response)
3355
    def document__save_edit_config(
3356
        request_form_data: FormData = Depends(parse_form_data),
3357
    ) -> Response:
3358
        """
3359
        @relation(SDOC-SRS-57, scope=function)
3360
        """
3361
 
3362
        request_dict: Dict[str, str] = dict(request_form_data)
3363
        document_mid: str = request_dict["document_mid"]
3364
        document: SDocDocument = (
3365
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3366
        )
3367
        if not export_action.traceability_index.can_edit_document(document):
3368
            raise HTTPException(
3369
                status_code=403,
3370
                detail="Editing is disabled for autogenerated content.",
3371
            )
3372
 
3373
        form_object: DocumentConfigFormObject = (
3374
            DocumentConfigFormObject.create_from_request(
3375
                document_mid=document_mid,
3376
                request_form_data=request_form_data,
3377
            )
3378
        )
3379
        try:
3380
            update_command = UpdateDocumentConfigTransform(
3381
                form_object=form_object,
3382
                document=document,
3383
                traceability_index=export_action.traceability_index,
3384
            )
3385
            update_command.perform()
3386
        except MultipleValidationError as validation_error:
3387
            for error_key, errors in validation_error.errors.items():
3388
                for error in errors:
3389
                    form_object.add_error(error_key, error)
3390
            html_output = env().render_template_as_markup(
3391
                "actions/"
3392
                "document/"
3393
                "edit_document_config/"
3394
                "stream_edit_document_config.jinja.html",
3395
                form_object=form_object,
3396
                document=document,
3397
            )
3398
            return HTMLResponse(
3399
                content=html_output,
3400
                status_code=422,
3401
                headers={
3402
                    "Content-Type": "text/vnd.turbo-stream.html",
3403
                },
3404
            )
3405
 
3406
        # Re-generate the document's SDOC.
3407
        write_document_to_file(document)
3408
 
3409
        # Update the index because other documents might be referenced by this
3410
        # document's free text. These documents will be regenerated on demand,
3411
        # when they are opened next time.
3412
        export_action.traceability_index.update_last_updated()
3413
 
3414
        assert document.meta is not None
3415
        link_renderer = LinkRenderer(
3416
            root_path=document.meta.get_root_path_prefix(),
3417
            static_path=project_config.dir_for_sdoc_assets,
3418
        )
3419
        markup_renderer = MarkupRenderer.create(
3420
            markup=document.config.get_markup(),
3421
            traceability_index=export_action.traceability_index,
3422
            link_renderer=link_renderer,
3423
            html_templates=html_generator.html_templates,
3424
            config=project_config,
3425
            context_document=document,
3426
        )
3427
        view_object = DocumentScreenViewObject(
3428
            document_type=DocumentType.DOCUMENT,
3429
            document=document,
3430
            traceability_index=export_action.traceability_index,
3431
            project_config=project_config,
3432
            link_renderer=link_renderer,
3433
            markup_renderer=markup_renderer,
3434
            jinja_environment=env(),
3435
            git_client=html_generator.git_client,
3436
        )
3437
        html_output = env().render_template_as_markup(
3438
            "actions/"
3439
            "document/"
3440
            "edit_document_config/"
3441
            "stream_save_document_config.jinja.html",
3442
            view_object=view_object,
3443
        )
3444
        return HTMLResponse(
3445
            content=html_output,
3446
            status_code=200,
3447
            headers={
3448
                "Content-Type": "text/vnd.turbo-stream.html",
3449
            },
3450
        )
3451
 
3452
    @write_router.post(
3453
        "/actions/document/save_included_document", response_class=Response
3454
    )
3455
    def document__save_included_document(
3456
        request_form_data: FormData = Depends(parse_form_data),
3457
    ) -> Response:
3458
        request_dict: Dict[str, str] = dict(request_form_data)
3459
        document_mid: str = request_dict["document_mid"]
3460
        context_document_mid: str = request_dict["context_document_mid"]
3461
        document: SDocDocument = (
3462
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3463
        )
3464
        context_document: SDocDocument = (
3465
            export_action.traceability_index.get_node_by_mid(
3466
                MID(context_document_mid)
3467
            )
3468
        )
3469
        form_object: IncludedDocumentFormObject = (
3470
            IncludedDocumentFormObject.create_from_request(
3471
                request_form_data=request_form_data, jinja_environment=env()
3472
            )
3473
        )
3474
        try:
3475
            update_command = UpdateIncludedDocumentTransform(
3476
                form_object=form_object,
3477
                document=document,
3478
                traceability_index=export_action.traceability_index,
3479
            )
3480
            update_command.perform()
3481
        except MultipleValidationError as validation_error:
3482
            for error_key, errors in validation_error.errors.items():
3483
                for error in errors:
3484
                    form_object.add_error(error_key, error)
3485
            return HTMLResponse(
3486
                content=form_object.render_edit_form(),
3487
                status_code=422,
3488
                headers={
3489
                    "Content-Type": "text/vnd.turbo-stream.html",
3490
                },
3491
            )
3492
 
3493
        # Re-generate the document's SDOC.
3494
        write_document_to_file(document)
3495
 
3496
        # Update the index because other documents might be referenced by this
3497
        # document's free text. These documents will be regenerated on demand,
3498
        # when they are opened next time.
3499
        export_action.traceability_index.update_last_updated()
3500
 
3501
        assert document.meta is not None
3502
        link_renderer = LinkRenderer(
3503
            root_path=document.meta.get_root_path_prefix(),
3504
            static_path=project_config.dir_for_sdoc_assets,
3505
        )
3506
        markup_renderer = MarkupRenderer.create(
3507
            markup=document.config.get_markup(),
3508
            traceability_index=export_action.traceability_index,
3509
            link_renderer=link_renderer,
3510
            html_templates=html_generator.html_templates,
3511
            config=project_config,
3512
            context_document=document,
3513
        )
3514
        view_object = DocumentScreenViewObject(
3515
            document_type=DocumentType.DOCUMENT,
3516
            document=context_document,
3517
            traceability_index=export_action.traceability_index,
3518
            project_config=project_config,
3519
            link_renderer=link_renderer,
3520
            markup_renderer=markup_renderer,
3521
            jinja_environment=env(),
3522
            git_client=html_generator.git_client,
3523
        )
3524
        return HTMLResponse(
3525
            content=view_object.render_updated_nodes_and_toc(
3526
                nodes=[document], node_updated=True
3527
            ),
3528
            status_code=200,
3529
            headers={
3530
                "Content-Type": "text/vnd.turbo-stream.html",
3531
            },
3532
        )
3533
 
3534
    @read_router.get(
3535
        "/actions/document/cancel_edit_config", response_class=Response
3536
    )
3537
    def document__cancel_edit_config(document_mid: str) -> Response:
3538
        """
3539
        @relation(SDOC-SRS-57, scope=function)
3540
        """
3541
 
3542
        document: SDocDocument = (
3543
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3544
        )
3545
        assert document.meta is not None
3546
        link_renderer = LinkRenderer(
3547
            root_path=document.meta.get_root_path_prefix(),
3548
            static_path=project_config.dir_for_sdoc_assets,
3549
        )
3550
        markup_renderer = MarkupRenderer.create(
3551
            markup=document.config.get_markup(),
3552
            traceability_index=export_action.traceability_index,
3553
            link_renderer=link_renderer,
3554
            html_templates=html_generator.html_templates,
3555
            config=project_config,
3556
            context_document=document,
3557
        )
3558
        view_object = DocumentScreenViewObject(
3559
            document_type=DocumentType.DOCUMENT,
3560
            document=document,
3561
            traceability_index=export_action.traceability_index,
3562
            project_config=project_config,
3563
            link_renderer=link_renderer,
3564
            markup_renderer=markup_renderer,
3565
            jinja_environment=env(),
3566
            git_client=html_generator.git_client,
3567
        )
3568
        output = env().render_template_as_markup(
3569
            "actions/"
3570
            "document/"
3571
            "edit_document_config/"
3572
            "stream_cancel_edit_document_config.jinja.html",
3573
            view_object=view_object,
3574
            document=document,
3575
        )
3576
        return HTMLResponse(
3577
            content=output,
3578
            status_code=200,
3579
            headers={
3580
                "Content-Type": "text/vnd.turbo-stream.html",
3581
            },
3582
        )
3583
 
3584
    @read_router.get(
3585
        "/actions/document/cancel_edit_included_document",
3586
        response_class=Response,
3587
    )
3588
    def document__cancel_edit_included_document(document_mid: str) -> Response:
3589
        document: SDocDocument = (
3590
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3591
        )
3592
        assert document.meta is not None
3593
        link_renderer = LinkRenderer(
3594
            root_path=document.meta.get_root_path_prefix(),
3595
            static_path=project_config.dir_for_sdoc_assets,
3596
        )
3597
        markup_renderer = MarkupRenderer.create(
3598
            markup=document.config.get_markup(),
3599
            traceability_index=export_action.traceability_index,
3600
            link_renderer=link_renderer,
3601
            html_templates=html_generator.html_templates,
3602
            config=project_config,
3603
            context_document=document,
3604
        )
3605
        view_object = DocumentScreenViewObject(
3606
            document_type=DocumentType.DOCUMENT,
3607
            document=document,
3608
            traceability_index=export_action.traceability_index,
3609
            project_config=project_config,
3610
            link_renderer=link_renderer,
3611
            markup_renderer=markup_renderer,
3612
            jinja_environment=env(),
3613
            git_client=html_generator.git_client,
3614
        )
3615
        output = env().render_template_as_markup(
3616
            "actions/document/edit_section/stream_updated_section.jinja.html",
3617
            view_object=view_object,
3618
            document=document,
3619
            node=document,
3620
        )
3621
        return HTMLResponse(
3622
            content=output,
3623
            status_code=200,
3624
            headers={
3625
                "Content-Type": "text/vnd.turbo-stream.html",
3626
            },
3627
        )
3628
 
3629
    @read_router.get("/actions/document/edit_grammar", response_class=Response)
3630
    def document__edit_grammar(document_mid: str) -> Response:
3631
        """
3632
        @relation(SDOC-SRS-56, scope=function)
3633
        """
3634
 
3635
        document: SDocDocument = (
3636
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3637
        )
3638
        form_object: GrammarFormObject = GrammarFormObject.create_from_document(
3639
            document=document,
3640
            project_config=project_config,
3641
            jinja_environment=env(),
3642
        )
3643
        return HTMLResponse(
3644
            content=form_object.render(),
3645
            status_code=200,
3646
            headers={
3647
                "Content-Type": "text/vnd.turbo-stream.html",
3648
            },
3649
        )
3650
 
3651
    @write_router.post(
3652
        "/actions/document/save_grammar", response_class=Response
3653
    )
3654
    def document__save_grammar(
3655
        request_form_data: FormData = Depends(parse_form_data),
3656
    ) -> Response:
3657
        """
3658
        @relation(SDOC-SRS-56, scope=function)
3659
        """
3660
 
3661
        request_dict: Dict[str, str] = dict(request_form_data)
3662
        document_mid: str = request_dict["document_mid"]
3663
        document: SDocDocument = (
3664
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3665
        )
3666
        form_object: GrammarFormObject = GrammarFormObject.create_from_request(
3667
            document_mid=document_mid,
3668
            request_form_data=request_form_data,
3669
            project_config=project_config,
3670
            jinja_environment=env(),
3671
        )
3672
        if not form_object.validate():
3673
            return HTMLResponse(
3674
                content=form_object.render(),
3675
                status_code=422,
3676
                headers={
3677
                    "Content-Type": "text/vnd.turbo-stream.html",
3678
                },
3679
            )
3680
        # Update the document with new grammar.
3681
        update_grammar_action = UpdateGrammarCommand(
3682
            form_object=form_object,
3683
            document=document,
3684
            traceability_index=export_action.traceability_index,
3685
        )
3686
        update_grammar_action.perform()
3687
 
3688
        # Re-generate the document's SDOC.
3689
        write_document_to_file(document)
3690
 
3691
        # Re-generate the document.
3692
        html_generator.export_single_document(
3693
            document=document,
3694
            traceability_index=export_action.traceability_index,
3695
        )
3696
 
3697
        # Re-generate the document tree.
3698
        html_generator.export_project_tree_screen(
3699
            traceability_index=export_action.traceability_index,
3700
        )
3701
 
3702
        assert document.meta is not None
3703
        link_renderer = LinkRenderer(
3704
            root_path=document.meta.get_root_path_prefix(),
3705
            static_path=project_config.dir_for_sdoc_assets,
3706
        )
3707
        markup_renderer = MarkupRenderer.create(
3708
            markup=document.config.get_markup(),
3709
            traceability_index=export_action.traceability_index,
3710
            link_renderer=link_renderer,
3711
            html_templates=html_generator.html_templates,
3712
            config=project_config,
3713
            context_document=document,
3714
        )
3715
        view_object = DocumentScreenViewObject(
3716
            document_type=DocumentType.DOCUMENT,
3717
            document=document,
3718
            traceability_index=export_action.traceability_index,
3719
            project_config=project_config,
3720
            link_renderer=link_renderer,
3721
            markup_renderer=markup_renderer,
3722
            jinja_environment=env(),
3723
            git_client=html_generator.git_client,
3724
        )
3725
        output = (
3726
            form_object.render_close_form()
3727
            + env().render_template_as_markup(
3728
                "actions/document/_shared/stream_refresh_document.jinja.html",
3729
                view_object=view_object,
3730
            )
3731
        )
3732
        return HTMLResponse(
3733
            content=output,
3734
            status_code=200,
3735
            headers={
3736
                "Content-Type": "text/vnd.turbo-stream.html",
3737
            },
3738
        )
3739
 
3740
    @read_router.get(
3741
        "/actions/document/add_grammar_element", response_class=Response
3742
    )
3743
    def document__add_grammar_element(document_mid: str) -> Response:
3744
        """
3745
        @relation(SDOC-SRS-56, scope=function)
3746
        """
3747
 
3748
        form_object: GrammarFormObject = GrammarFormObject(
3749
            document_mid=document_mid,
3750
            fields=[],  # Not used in this limited partial template.
3751
            project_config=project_config,
3752
            jinja_environment=env(),
3753
            imported_grammar_file=None,
3754
        )
3755
        return HTMLResponse(
3756
            content=form_object.render_row_with_new_grammar_element(),
3757
            status_code=200,
3758
            headers={
3759
                "Content-Type": "text/vnd.turbo-stream.html",
3760
            },
3761
        )
3762
 
3763
    @read_router.get(
3764
        "/actions/document/edit_grammar_element", response_class=Response
3765
    )
3766
    def document__edit_grammar_element(
3767
        document_mid: str, element_mid: str
3768
    ) -> Response:
3769
        """
3770
        @relation(SDOC-SRS-56, scope=function)
3771
        """
3772
 
3773
        document: SDocDocument = (
3774
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3775
        )
3776
        form_object: GrammarElementFormObject = (
3777
            GrammarElementFormObject.create_from_document(
3778
                document=document,
3779
                element_mid=element_mid,
3780
                project_config=project_config,
3781
                jinja_environment=env(),
3782
            )
3783
        )
3784
 
3785
        return HTMLResponse(
3786
            content=form_object.render(),
3787
            status_code=200,
3788
            headers={
3789
                "Content-Type": "text/vnd.turbo-stream.html",
3790
            },
3791
        )
3792
 
3793
    @write_router.post(
3794
        "/actions/document/save_grammar_element", response_class=Response
3795
    )
3796
    def document__save_grammar_element(
3797
        request_form_data: FormData = Depends(parse_form_data),
3798
    ) -> Response:
3799
        """
3800
        @relation(SDOC-SRS-56, scope=function)
3801
        """
3802
 
3803
        request_dict: Dict[str, str] = dict(request_form_data)
3804
        document_mid: str = request_dict["document_mid"]
3805
        document: SDocDocument = (
3806
            export_action.traceability_index.get_node_by_mid(MID(document_mid))
3807
        )
3808
        form_object: GrammarElementFormObject = (
3809
            GrammarElementFormObject.create_from_request(
3810
                document=document,
3811
                request_form_data=request_form_data,
3812
                project_config=project_config,
3813
                jinja_environment=env(),
3814
            )
3815
        )
3816
        if not form_object.validate():
3817
            return HTMLResponse(
3818
                content=form_object.render_after_validation(),
3819
                status_code=422,
3820
                headers={
3821
                    "Content-Type": "text/vnd.turbo-stream.html",
3822
                },
3823
            )
3824
 
3825
        # Update the document with new grammar.
3826
        update_grammar_action = UpdateGrammarElementCommand(
3827
            form_object=form_object,
3828
            document=document,
3829
            traceability_index=export_action.traceability_index,
3830
        )
3831
        update_grammar_action.perform()
3832
 
3833
        # Re-generate the document's SDOC.
3834
        write_document_to_file(document)
3835
 
3836
        # Re-generate the document.
3837
        html_generator.export_single_document(
3838
            document=document,
3839
            traceability_index=export_action.traceability_index,
3840
        )
3841
 
3842
        # Re-generate the document tree.
3843
        html_generator.export_project_tree_screen(
3844
            traceability_index=export_action.traceability_index,
3845
        )
3846
 
3847
        assert document.meta is not None
3848
        link_renderer = LinkRenderer(
3849
            root_path=document.meta.get_root_path_prefix(),
3850
            static_path=project_config.dir_for_sdoc_assets,
3851
        )
3852
        markup_renderer = MarkupRenderer.create(
3853
            markup=document.config.get_markup(),
3854
            traceability_index=export_action.traceability_index,
3855
            link_renderer=link_renderer,
3856
            html_templates=html_generator.html_templates,
3857
            config=project_config,
3858
            context_document=document,
3859
        )
3860
        view_object = DocumentScreenViewObject(
3861
            document_type=DocumentType.DOCUMENT,
3862
            document=document,
3863
            traceability_index=export_action.traceability_index,
3864
            project_config=project_config,
3865
            link_renderer=link_renderer,
3866
            markup_renderer=markup_renderer,
3867
            jinja_environment=env(),
3868
            git_client=html_generator.git_client,
3869
        )
3870
        output = (
3871
            form_object.render_close_form()
3872
            + env().render_template_as_markup(
3873
                "actions/document/_shared/stream_refresh_document.jinja.html",
3874
                view_object=view_object,
3875
            )
3876
        )
3877
        return HTMLResponse(
3878
            content=output,
3879
            status_code=200,
3880
            headers={
3881
                "Content-Type": "text/vnd.turbo-stream.html",
3882
            },
3883
        )
3884
 
3885
    @read_router.get(
3886
        "/actions/document/add_grammar_field", response_class=Response
3887
    )
3888
    def document__add_grammar_field(document_mid: str) -> Response:
3889
        """
3890
        @relation(SDOC-SRS-56, scope=function)
3891
        """
3892
 
3893
        form_object: GrammarElementFormObject = GrammarElementFormObject(
3894
            document_mid=document_mid,
3895
            element_mid="NOT_RELEVANT",
3896
            element_name="NOT_RELEVANT",
3897
            is_composite=None,  # Not used in this limited partial template.
3898
            prefix=None,  # Not used in this limited partial template.
3899
            view_style=None,  # Not used in this limited partial template.
3900
            fields=[],  # Not used in this limited partial template.
3901
            relations=[],  # Not used in this limited partial template.
3902
            project_config=project_config,
3903
            jinja_environment=env(),
3904
        )
3905
        return HTMLResponse(
3906
            content=form_object.render_row_with_new_field(),
3907
            status_code=200,
3908
            headers={
3909
                "Content-Type": "text/vnd.turbo-stream.html",
3910
            },
3911
        )
3912
 
3913
    @read_router.get(
3914
        "/actions/document/add_grammar_relation", response_class=Response
3915
    )
3916
    def document__add_grammar_relation(document_mid: str) -> Response:
3917
        """
3918
        @relation(SDOC-SRS-56, scope=function)
3919
        """
3920
 
3921
        form_object = GrammarElementFormObject(
3922
            document_mid=document_mid,
3923
            element_mid="NOT_RELEVANT",
3924
            element_name="NOT_RELEVANT",
3925
            is_composite=None,  # Not used in this limited partial template.
3926
            prefix=None,  # Not used in this limited partial template.
3927
            view_style=None,  # Not used in this limited partial template.
3928
            fields=[],  # Not used in this limited partial template.
3929
            relations=[],  # Not used in this limited partial template.
3930
            project_config=project_config,
3931
            jinja_environment=env(),
3932
        )
3933
        return HTMLResponse(
3934
            content=form_object.render_row_with_new_relation(),
3935
            status_code=200,
3936
            headers={
3937
                "Content-Type": "text/vnd.turbo-stream.html",
3938
            },
3939
        )
3940
 
3941
    @read_router.get(
3942
        "/actions/project_index/import_reqif_document_form",
3943
        response_class=Response,
3944
    )
3945
    def get_import_reqif_document_form() -> Response:
3946
        output = env().render_template_as_markup(
3947
            "actions/project_index/import_reqif_document/"
3948
            "stream_form_import_reqif_document.jinja.html",
3949
            error_object=ErrorObject(),
3950
        )
3951
        return HTMLResponse(
3952
            content=output,
3953
            status_code=200,
3954
            headers={
3955
                "Content-Type": "text/vnd.turbo-stream.html",
3956
            },
3957
        )
3958
 
3959
    @write_router.post(
3960
        "/actions/project_index/import_document_reqif", response_class=Response
3961
    )
3962
    def import_document_reqif(reqif_file: UploadFile) -> Response:
3963
        contents = reqif_file.file.read().decode()
3964
 
3965
        error_object = ErrorObject()
3966
        assert isinstance(contents, str)
3967
 
3968
        try:
3969
            reqif_bundle = ReqIFParser.parse_from_string(contents)
3970
            converter: P01_ReqIFToSDocConverter = P01_ReqIFToSDocConverter()
3971
            documents: List[SDocDocument] = converter.convert_reqif_bundle(
3972
                reqif_bundle,
3973
                enable_mid=project_config.reqif_enable_mid,
3974
                import_markup=project_config.reqif_import_markup,
3975
            )
3976
        except ReqIFXMLParsingError as exception:
3977
            error_object.add_error(
3978
                "reqif_file", "Cannot parse ReqIF file: " + str(exception)
3979
            )
3980
        # Catch unexpected errors but exclude from code coverage, because it is
3981
        # not clear yet how to write a test that triggers this.
3982
        except Exception as exception:  # pragma: no cover
3983
            error_object.add_error("reqif_file", str(exception))
3984
 
3985
        if error_object.any_errors():
3986
            output = env().render_template_as_markup(
3987
                "actions/project_index/import_reqif_document/"
3988
                "stream_form_import_reqif_document.jinja.html",
3989
                error_object=error_object,
3990
            )
3991
            return HTMLResponse(
3992
                content=output,
3993
                status_code=422,
3994
                headers={
3995
                    "Content-Type": "text/vnd.turbo-stream.html",
3996
                },
3997
            )
3998
        assert documents is not None
3999
        assert isinstance(project_config.input_paths, list)
4000
        for document in documents:
4001
            document_title = re.sub(r"[^A-Za-z0-9-]", "_", document.title)
4002
            document_path = f"{document_title}.sdoc"
4003
 
4004
            full_input_path = os.path.abspath(project_config.input_paths[0])
4005
            doc_full_path = os.path.join(full_input_path, document_path)
4006
            doc_full_path_dir = os.path.dirname(doc_full_path)
4007
            Path(doc_full_path_dir).mkdir(parents=True, exist_ok=True)
4008
 
4009
            file_tree_mount_folder = os.path.basename(
4010
                os.path.dirname(full_input_path)
4011
            )
4012
 
4013
            input_doc_assets_dir_rel_path = "/".join(
4014
                (file_tree_mount_folder, "_assets")
4015
            )
4016
 
4017
            # FIXME: Fill in the meta information correctly.
4018
            document.meta = DocumentMeta(
4019
                level=0,
4020
                file_tree_mount_folder="NOT_RELEVANT",
4021
                document_filename=document_path,
4022
                document_filename_base="NOT_RELEVANT",
4023
                input_doc_full_path=doc_full_path,
4024
                input_doc_rel_path=SDocRelativePath(document_path),
4025
                input_doc_dir_rel_path=SDocRelativePath(""),
4026
                input_doc_assets_dir_rel_path=SDocRelativePath(
4027
                    input_doc_assets_dir_rel_path
4028
                ),
4029
                output_document_dir_full_path="NOT_RELEVANT",
4030
                output_document_dir_rel_path=SDocRelativePath("FIXME"),
4031
            )
4032
 
4033
            write_document_to_file(document)
4034
 
4035
        export_action.build_index()
4036
        export_action.export()
4037
 
4038
        view_object = ProjectTreeViewObject(
4039
            traceability_index=export_action.traceability_index,
4040
            project_config=project_config,
4041
        )
4042
        output = env().render_template_as_markup(
4043
            "actions/project_index/import_reqif_document/"
4044
            "stream_refresh_with_imported_reqif_document.jinja.html",
4045
            view_object=view_object,
4046
        )
4047
        return HTMLResponse(
4048
            content=output,
4049
            status_code=200,
4050
            headers={
4051
                "Content-Type": "text/vnd.turbo-stream.html",
4052
            },
4053
        )
4054
 
4055
    @router.get("/export_html2pdf/{document_mid}", response_class=Response)
4056
    def get_export_html2pdf(document_mid: str) -> Response:  # noqa: ARG001
4057
        if not project_config.is_activated_html2pdf():
4058
            return Response(
4059
                content="The HTML2PDF feature is not activated in the project config.",
4060
                status_code=HTTP_STATUS_PRECONDITION_FAILED,
4061
            )
4062
 
4063
        with lock_manager.acquire_subset(
4064
            read_ids={_compute_document_mid_lock_key(document_mid)}
4065
        ):
4066
            document = export_action.traceability_index.get_node_by_mid(
4067
                MID(document_mid)
4068
            )
4069
 
4070
            root_path = document.meta.get_root_path_prefix()
4071
            relative_path = (
4072
                document.meta.output_document_dir_rel_path.relative_path
4073
            )
4074
 
4075
            link_renderer = LinkRenderer(
4076
                root_path=root_path,
4077
                static_path=project_config.dir_for_sdoc_assets,
4078
            )
4079
            markup_renderer = MarkupRenderer.create(
4080
                document.config.get_markup(),
4081
                export_action.traceability_index,
4082
                link_renderer,
4083
                html_templates,
4084
                project_config,
4085
                document,
4086
            )
4087
 
4088
            pdf_project_config = copy.deepcopy(project_config)
4089
            pdf_project_config.is_running_on_server = False
4090
 
4091
            with measure_performance("Generating printable HTML document"):
4092
                document_content = DocumentHTML2PDFGenerator.export(
4093
                    pdf_project_config,
4094
                    document,
4095
                    export_action.traceability_index,
4096
                    markup_renderer,
4097
                    link_renderer,
4098
                    git_client=html_generator.git_client,
4099
                    html_templates=html_templates,
4100
                )
4101
 
4102
            # Copy values needed below so the expensive filesystem and subprocess
4103
            # phase can run without holding the router's read lock.
4104
            proposed_basename = "document"
4105
            if document.title is not None:
4106
                proposed_basename = document.title
4107
            if document.uid is not None:
4108
                proposed_basename = document.uid + " " + proposed_basename
4109
 
4110
        temp_uid = uuid.uuid4().hex
4111
        path_to_output_html = os.path.join(
4112
            project_config.export_output_html_root,
4113
            relative_path,
4114
            f"_temp_{temp_uid}.html",
4115
        )
4116
        path_to_output_pdf = os.path.join(
4117
            project_config.export_output_html_root,
4118
            "html",
4119
            f"_temp_{temp_uid}.pdf",
4120
        )
4121
 
4122
        def cleanup_html2pdf_artifacts() -> None:
4123
            for path in (path_to_output_html, path_to_output_pdf):
4124
                if os.path.isfile(path):
4125
                    os.remove(path)
4126
 
4127
        Path(path_to_output_html).parent.mkdir(parents=True, exist_ok=True)
4128
        Path(path_to_output_pdf).parent.mkdir(parents=True, exist_ok=True)
4129
 
4130
        # FIXME: Add this print driver to a service bus object to make it
4131
        #        unit-testable.
4132
        pdf_print_driver = PDFPrintDriver()
4133
        with open(path_to_output_html, mode="w", encoding="utf8") as temp_file_:
4134
            temp_file_.write(document_content)
4135
 
4136
        assert os.path.isfile(path_to_output_html), path_to_output_html
4137
        try:
4138
            pdf_print_driver.get_pdf_from_html(
4139
                project_config,
4140
                [(path_to_output_html, path_to_output_pdf)],
4141
                project_config.export_output_html_root,
4142
            )
4143
        except PDFPrintDriverException as e_:  # pragma: no cover
4144
            cleanup_html2pdf_artifacts()
4145
            return Response(
4146
                content=e_.get_server_user_message(),
4147
                status_code=HTTP_STATUS_INTERNAL_SERVER_ERROR,
4148
            )
4149
        assert os.path.isfile(path_to_output_pdf), path_to_output_pdf
4150
 
4151
        # We sanitize the basename, Windows is the most restrictive:
4152
        # - many forbidden chars.
4153
        # - not more than 120 chars in total, including the PDF extension
4154
        forbidden = '<>:"/\\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f'
4155
        table = str.maketrans(forbidden, "_" * len(forbidden))
4156
        sanitized_basename = proposed_basename.translate(table)
4157
        sanitized_basename = sanitized_basename.strip(" ")[:115]
4158
        encoded_filename = quote(sanitized_basename + ".pdf")
4159
 
4160
        return FileResponse(
4161
            path=path_to_output_pdf,
4162
            status_code=200,
4163
            headers={
4164
                "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
4165
            },
4166
            media_type="application/octet-stream",
4167
            background=BackgroundTask(cleanup_html2pdf_artifacts),
4168
        )
4169
 
4170
    @read_router.get(
4171
        "/reqif/export_document/{document_mid}", response_class=Response
4172
    )
4173
    def get_reqif_export_document(document_mid: str) -> Response:  # noqa: ARG001
4174
        # TODO: Export single document, not the whole tree.
4175
        return get_reqif_export_tree()
4176
 
4177
    @read_router.get("/reqif/export_tree", response_class=Response)
4178
    def get_reqif_export_tree() -> Response:
4179
        reqif_bundle = P01_SDocToReqIFObjectConverter.convert_document_tree(
4180
            document_tree=export_action.traceability_index.document_tree,
4181
            multiline_is_xhtml=project_config.reqif_multiline_is_xhtml,
4182
            enable_mid=project_config.reqif_enable_mid,
4183
        )
4184
        reqif_content: str = ReqIFUnparser.unparse(reqif_bundle)
4185
        return Response(
4186
            content=reqif_content,
4187
            status_code=200,
4188
            media_type="application/octet-stream",
4189
            headers={
4190
                "Content-Disposition": 'attachment; filename="export.reqif"',
4191
            },
4192
        )
4193
 
4194
    @read_router.get("/search", response_class=Response)
4195
    def get_search(q: Optional[str] = None) -> Response:
4196
        if not project_config.is_activated_search():
4197
            return Response(
4198
                content="The Search feature is not activated in the project config.",
4199
                status_code=HTTP_STATUS_PRECONDITION_FAILED,
4200
            )
4201
        search_results = []
4202
        error = None
4203
        node_query = None
4204
        plain_text_query_phrase = None
4205
        plain_text_query_pattern = None
4206
 
4207
        if q is not None and len(q) > 0:
4208
            normalized_query = q.strip()
4209
            if len(normalized_query) > 0:
4210
                if search_query_contains_markers(normalized_query):
4211
                    try:
4212
                        query: Query = QueryReader.read(normalized_query)
4213
                        node_query = QueryObject(
4214
                            query, export_action.traceability_index
4215
                        )
4216
                    except Exception as e:
4217
                        error = f"error: {e}"
4218
                else:
4219
                    (
4220
                        plain_text_query_phrase,
4221
                        plain_text_query_pattern,
4222
                    ) = parse_plain_text_search_query(normalized_query)
4223
 
4224
        if (
4225
            node_query is not None
4226
            or plain_text_query_phrase is not None
4227
            or plain_text_query_pattern is not None
4228
        ):
4229
            result: List[SDocExtendedElementIF] = []
4230
            try:
4231
                document_tree = assert_cast(
4232
                    export_action.traceability_index.document_tree, DocumentTree
4233
                )
4234
                for document in document_tree.document_list:
4235
                    document_iterator = (
4236
                        export_action.traceability_index.get_document_iterator(
4237
                            document
4238
                        )
4239
                    )
4240
                    for node, _ in document_iterator.all_content(
4241
                        print_fragments=False
4242
                    ):
4243
                        if (
4244
                            node_query is not None and node_query.evaluate(node)
4245
                        ) or (
4246
                            search_node_matches_plain_text_query(
4247
                                node,
4248
                                phrase=plain_text_query_phrase,
4249
                                pattern=plain_text_query_pattern,
4250
                            )
4251
                        ):
4252
                            result.append(node)
4253
 
4254
                if (
4255
                    export_action.traceability_index.document_tree.source_tree
4256
                    is not None
4257
                ):
4258
                    for source_file_ in export_action.traceability_index.document_tree.source_tree.source_files:
4259
                        source_file_info_: SourceFileTraceabilityInfo = export_action.traceability_index.get_file_traceability_index().get_coverage_info(
4260
                            source_file_.in_doctree_source_file_rel_path_posix
4261
                        )
4262
                        if (
4263
                            node_query is not None
4264
                            and node_query.evaluate(source_file_info_)
4265
                        ) or (
4266
                            search_node_matches_plain_text_query(
4267
                                source_file_info_,
4268
                                phrase=plain_text_query_phrase,
4269
                                pattern=plain_text_query_pattern,
4270
                            )
4271
                        ):
4272
                            result.append(source_file_info_)
4273
 
4274
                search_results = result
4275
            # Catch unexpected errors but exclude from code coverage, because
4276
            # it is not clear yet how to write a test that triggers this.
4277
            except (
4278
                AttributeError,
4279
                NameError,
4280
                TypeError,
4281
            ) as attribute_error_:  # pragma: no cover
4282
                error = attribute_error_.args[0]
4283
 
4284
        view_object = SearchScreenViewObject(
4285
            traceability_index=export_action.traceability_index,
4286
            project_config=project_config,
4287
            templates=html_templates,
4288
            search_results=search_results,
4289
            search_value=q if q is not None else "",
4290
            error=error,
4291
        )
4292
        output = view_object.render_screen(html_templates.jinja_environment())
4293
 
4294
        return Response(
4295
            content=output,
4296
            status_code=200,
4297
        )
4298
 
4299
    @read_router.get("/autocomplete/uid", response_class=Response)
4300
    def get_autocomplete_uid_results(
4301
        q: Optional[str] = None, exclude_requirement_mid: Optional[str] = None
4302
    ) -> Response:
4303
        """
4304
        Returns matches of possible node UID values when creating a node relation.
4305
 
4306
        The UID of the node identified by the optional parameter "exclude_requirement_mid" is excluded,
4307
        so that a node cannot be linked to itself.
4308
 
4309
        @relation(SDOC-SRS-120, scope=function)
4310
        """
4311
        output = ""
4312
        if q is not None:
4313
            query_words = q.lower().split()
4314
            resulting_nodes = []
4315
            document_tree = assert_cast(
4316
                export_action.traceability_index.document_tree, DocumentTree
4317
            )
4318
            for document in document_tree.document_list:
4319
                document_iterator = (
4320
                    export_action.traceability_index.get_document_iterator(
4321
                        document
4322
                    )
4323
                )
4324
                for node_, _ in document_iterator.all_content(
4325
                    print_fragments=False
4326
                ):
4327
                    if not isinstance(node_, SDocNodeIF):
4328
                        continue
4329
 
4330
                    if node_.node_type == "SECTION":
4331
                        continue
4332
 
4333
                    if (
4334
                        node_.reserved_uid is not None
4335
                        and node_.reserved_mid != exclude_requirement_mid
4336
                    ):
4337
                        words_ = node_.reserved_uid.strip().lower()
4338
                        if node_.reserved_title is not None:
4339
                            words_ = (
4340
                                words_
4341
                                + " "
4342
                                + node_.reserved_title.strip().lower()
4343
                            )
4344
                        if all(word_ in words_ for word_ in query_words):
4345
                            resulting_nodes.append(node_)
4346
 
4347
                            # Excluding the following branch from code coverage
4348
                            # because it is not practical to create a test that
4349
                            # reproduces going above the limit. The code is
4350
                            # simple, so it should be safe to exclude this
4351
                            # branch from coverage.
4352
                            if (
4353
                                len(resulting_nodes) >= AUTOCOMPLETE_LIMIT
4354
                            ):  # pragma: no cover
4355
                                break
4356
 
4357
            output = env().render_template_as_markup(
4358
                "autocomplete/uid/stream_autocomplete_uid.jinja.html",
4359
                nodes=resulting_nodes,
4360
            )
4361
 
4362
        return Response(
4363
            content=output,
4364
            status_code=200,
4365
        )
4366
 
4367
    @read_router.get("/autocomplete/field", response_class=Response)
4368
    def get_autocomplete_field_results(
4369
        q: Optional[str] = None,
4370
        document_mid: Optional[str] = None,
4371
        element_type: Optional[str] = None,
4372
        field_name: Optional[str] = None,
4373
    ) -> Response:
4374
        """
4375
        Returns matches of possible values of a SingleChoice, MultiChoice or Tag field.
4376
 
4377
        The field is identified by the document_mid, the element_type, and the field_name.
4378
        """
4379
        output = ""
4380
        if (
4381
            q is not None
4382
            and document_mid is not None
4383
            and element_type is not None
4384
        ):
4385
            document: SDocDocument = (
4386
                export_action.traceability_index.get_node_by_mid(
4387
                    MID(document_mid)
4388
                )
4389
            )
4390
            if document:
4391
                assert field_name is not None
4392
                all_options = document.get_options_for_field(
4393
                    element_type, field_name
4394
                )
4395
                field: GrammarElementField = (
4396
                    document.get_grammar_element_field_for(
4397
                        element_type, field_name
4398
                    )
4399
                )
4400
 
4401
                if field.gef_type in (
4402
                    RequirementFieldType.MULTIPLE_CHOICE,
4403
                    RequirementFieldType.TAG,
4404
                ):
4405
                    # MultipleChoice/Tag: We split the query into its parts:
4406
                    #
4407
                    # Example User input: "Some Value, Another Value, Yet ano|".
4408
                    # parts = ['some value', 'another value', 'yet ano']                # noqa: ERA001
4409
                    parts = q.lower().split(",")
4410
 
4411
                    # For the lookup, we want to use the only the last, still
4412
                    # incomplete part, not the full query:
4413
                    #
4414
                    # last_part = "yet ano"                                             # noqa: ERA001
4415
                    # query_words = ['yet', 'ano']                                      # noqa: ERA001
4416
                    last_part = parts[-1].strip()
4417
                    query_words = last_part.split()
4418
 
4419
                    # We also filter the already selected choices from the
4420
                    # options we are going to be send to the user,
4421
                    # as MultipleChoices is a Set, so options shall be
4422
                    # selectable at most once.
4423
                    #
4424
                    # In the example, we would remove 'some value' and 'another value'.
4425
                    already_selected = [
4426
                        p.strip() for p in parts[:-1] if p.strip()
4427
                    ]
4428
                    filtered_options = [
4429
                        choice
4430
                        for choice in all_options
4431
                        if choice.lower() not in already_selected
4432
                    ]
4433
                else:
4434
                    # SingleChoice: we use the full query and all available
4435
                    # options. There is no notion of an "already selected"
4436
                    # segment, as a SingleChoice field holds only one value.
4437
                    query_words = q.lower().split()
4438
                    filtered_options = all_options
4439
                    last_part = None
4440
 
4441
                resulting_values = []
4442
 
4443
                # Now filter the remaining options for those that match all words in query_words.
4444
                for option_ in filtered_options:
4445
                    words_ = option_.strip().lower()
4446
 
4447
                    if all(word_ in words_ for word_ in query_words):
4448
                        # A MultipleChoice/Tag option that exactly matches
4449
                        # the segment currently being typed is, in fact,
4450
                        # already a complete value of the field (typed in
4451
                        # full, with or without a trailing comma). It is
4452
                        # still shown so the user has visual confirmation,
4453
                        # but marked as already selected so it can't be
4454
                        # inserted as a duplicate.
4455
                        is_selected = last_part is not None and (
4456
                            words_ == last_part
4457
                        )
4458
                        resulting_values.append((option_, is_selected))
4459
                    if len(resulting_values) >= AUTOCOMPLETE_LIMIT:
4460
                        break
4461
 
4462
            output = env().render_template_as_markup(
4463
                "autocomplete/field/stream_autocomplete_field.jinja.html",
4464
                values=resulting_values,
4465
            )
4466
 
4467
        return Response(
4468
            content=output,
4469
            status_code=200,
4470
        )
4471
 
4472
    @read_router.get("/UID/{uid_or_mid}", response_class=RedirectResponse)
4473
    def redirect_to_uid(uid_or_mid: str) -> Response:
4474
        # Resolve UID or MID.
4475
 
4476
        linkable_node: Optional[Any] = (
4477
            export_action.traceability_index.get_node_by_mid_weak(
4478
                MID(uid_or_mid)
4479
            )
4480
        )
4481
        if linkable_node is None:
4482
            linkable_node = (
4483
                export_action.traceability_index.get_linkable_node_by_uid_weak(
4484
                    uid_or_mid
4485
                )
4486
            )
4487
 
4488
        # If found, send a 302 redirect response to guide the user to the
4489
        # correct URL (page + #anchor)
4490
        if linkable_node is not None:
4491
            link_renderer = LinkRenderer(
4492
                root_path="", static_path=project_config.dir_for_sdoc_assets
4493
            )
4494
            href = link_renderer.render_node_link(
4495
                linkable_node, None, document_type=DocumentType.DOCUMENT
4496
            )
4497
            return RedirectResponse(url=href, status_code=302)
4498
        # The HTTPException will render our ServerErrorViewObject 404 page
4499
        # via @app.exception_handler(404).
4500
        raise HTTPException(status_code=404, detail="UID or MID was not found")
4501
 
4502
    # Nestor is a highly experimental feature that is unlikely to make it to the
4503
    # stable feature set. Excluding it from code coverage.
4504
    @write_router.get("/__nestor", response_class=Response)  # pragma: no cover
4505
    def get_nestor() -> Response:  # pragma: no cover
4506
        output_json_root = os.path.join(project_config.output_dir, "html")
4507
        Path(output_json_root).mkdir(parents=True, exist_ok=True)
4508
        JSONGenerator().export_tree(
4509
            export_action.traceability_index, project_config, output_json_root
4510
        )
4511
        path_to_json = os.path.join("index.json")
4512
        view_object = NestorViewObject(
4513
            traceability_index=export_action.traceability_index,
4514
            project_config=project_config,
4515
            templates=html_templates,
4516
            path_to_json=path_to_json,
4517
        )
4518
        output = view_object.render_screen(html_templates.jinja_environment())
4519
 
4520
        return Response(
4521
            content=output,
4522
            status_code=200,
4523
        )
4524
 
4525
    router.include_router(read_router)
4526
    router.include_router(write_router)
4527
 
4528
    @router.get(
4529
        "/{full_path:path}/static_html_search_index.js", response_class=Response
4530
    )
4531
    def get_static_search_index(
4532
        request: Request,
4533
        full_path: str,  # noqa: ARG001
4534
    ) -> Response:
4535
        static_file = os.path.join(
4536
            project_config.export_output_html_root,
4537
            project_config.dir_for_sdoc_assets,
4538
            "static_html_search_index.js",
4539
        )
4540
 
4541
        def must_generate() -> bool:
4542
            if not os.path.isfile(static_file):
4543
                return True
4544
            output_file_mtime = get_file_modification_time(static_file)
4545
            return (
4546
                export_action.traceability_index.index_last_updated
4547
                > output_file_mtime
4548
            )
4549
 
4550
        with lock_manager.acquire_global_read():
4551
            if not must_generate() and request_is_for_non_modified_file(
4552
                request, static_file
4553
            ):
4554
                return Response(status_code=304)
4555
 
4556
        with lock_manager.acquire_global_write():
4557
            html_generator.export_static_html_search_index(
4558
                traceability_index=export_action.traceability_index
4559
            )
4560
 
4561
        return FileResponse(
4562
            static_file,
4563
            media_type="application/javascript",
4564
            headers={
4565
                # We don't want the search index to be cached on the server without
4566
                # revalidation.
4567
                # The no-cache request directive asks caches to validate the
4568
                # response with the origin server before reuse.
4569
                # no-cache allows clients to request the most up-to-date
4570
                # response even if the cache has a fresh response.
4571
                # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
4572
                "Cache-Control": "no-cache"
4573
            },
4574
        )
4575
 
4576
    @router.get("/{full_path:path}", response_class=Response)
4577
    def get_incoming_request(request: Request, full_path: str) -> Response:
4578
        # FIXME: This seems to be quite un-sanitized.
4579
        _, file_extension = os.path.splitext(full_path)
4580
        if file_extension == ".html":
4581
            return get_document(request, full_path)
4582
        elif file_extension == "":
4583
            # No extension: StrictDoc documents always end in .html, so no
4584
            # extension can ever resolve to a valid document. Return 404
4585
            # directly without going through get_document().
4586
            return _error_response(HTTP_STATUS_NOT_FOUND)
4587
        else:
4588
            return get_asset(request, full_path)
4589
 
4590
    def get_document(request: Request, url_to_document: str) -> Response:
4591
        """
4592
        @relation(SDOC-SRS-4, scope=function)
4593
        """
4594
 
4595
        document_relative_path: SDocRelativePath = SDocRelativePath.from_url(
4596
            url_to_document
4597
        )
4598
        full_path_to_document = os.path.join(
4599
            project_config.export_output_html_root,
4600
            document_relative_path.relative_path,
4601
        )
4602
 
4603
        def must_generate() -> bool:
4604
            if not os.path.isfile(full_path_to_document):
4605
                return True
4606
            output_file_mtime = get_file_modification_time(
4607
                full_path_to_document
4608
            )
4609
            return (
4610
                export_action.traceability_index.index_last_updated
4611
                > output_file_mtime
4612
            )
4613
 
4614
        with lock_manager.acquire_global_read():
4615
            if not must_generate():
4616
                if request_is_for_non_modified_file(
4617
                    request, full_path_to_document
4618
                ):
4619
                    return Response(status_code=304)
4620
                return FileResponse(
4621
                    full_path_to_document,
4622
                    media_type="text/html",
4623
                    headers={
4624
                        # We don't want the documents to be cached on the server without
4625
                        # revalidation.
4626
                        # The no-cache request directive asks caches to validate the
4627
                        # response with the origin server before reuse.
4628
                        # no-cache allows clients to request the most up-to-date
4629
                        # response even if the cache has a fresh response.
4630
                        # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
4631
                        "Cache-Control": "no-cache"
4632
                    },
4633
                )
4634
 
4635
        lock_key = _compute_document_generation_lock_key(
4636
            document_relative_path.relative_path
4637
        )
4638
 
4639
        with lock_manager.acquire_subset(write_ids={lock_key}):
4640
            if not must_generate():
4641
                if request_is_for_non_modified_file(
4642
                    request, full_path_to_document
4643
                ):
4644
                    return Response(status_code=304)
4645
                return FileResponse(
4646
                    full_path_to_document,
4647
                    media_type="text/html",
4648
                    headers={"Cache-Control": "no-cache"},
4649
                )
4650
 
4651
            def generate_document() -> Optional[Response]:
4652
                if document_relative_path.relative_path.startswith(
4653
                    "_source_files"
4654
                ):
4655
                    if document_relative_path.relative_path.endswith(
4656
                        "source_coverage.html"
4657
                    ):
4658
                        html_generator.export_source_coverage_screen(
4659
                            traceability_index=export_action.traceability_index,
4660
                        )
4661
                    else:
4662
                        try:
4663
                            html_generator.export_single_source_file_screen(
4664
                                traceability_index=export_action.traceability_index,
4665
                                path_to_source_file=document_relative_path.relative_path,
4666
                            )
4667
                        except FileNotFoundError:
4668
                            return _error_response(HTTP_STATUS_NOT_FOUND)
4669
                elif document_relative_path.relative_path == "index.html":
4670
                    html_generator.export_project_tree_screen(
4671
                        traceability_index=export_action.traceability_index,
4672
                    )
4673
                elif (
4674
                    document_relative_path.relative_path
4675
                    == "traceability_matrix.html"
4676
                ):
4677
                    if not project_config.is_activated_requirements_coverage():
4678
                        return Response(
4679
                            content="The Requirements Coverage feature is not activated in the project config.",
4680
                            status_code=HTTP_STATUS_PRECONDITION_FAILED,
4681
                        )
4682
                    html_generator.export_requirements_coverage_screen(
4683
                        traceability_index=export_action.traceability_index,
4684
                    )
4685
                elif document_relative_path.relative_path == "tree_map.html":
4686
                    if not project_config.is_activated_tree_map():
4687
                        return Response(
4688
                            content="The Tree Map feature is not activated in the project config.",
4689
                            status_code=HTTP_STATUS_PRECONDITION_FAILED,
4690
                        )
4691
                    html_generator.export_tree_map_screen(
4692
                        traceability_index=export_action.traceability_index,
4693
                    )
4694
                elif (
4695
                    document_relative_path.relative_path
4696
                    == "source_coverage.html"
4697
                ):
4698
                    if not project_config.is_activated_requirements_to_source_traceability():
4699
                        return Response(
4700
                            content="The Requirements to Source Files feature is not activated in the project config.",
4701
                            status_code=HTTP_STATUS_PRECONDITION_FAILED,
4702
                        )
4703
                    html_generator.export_source_coverage_screen(
4704
                        traceability_index=export_action.traceability_index,
4705
                    )
4706
                elif (
4707
                    feature_ := server_features_by_screen_filename.get(
4708
                        document_relative_path.relative_path
4709
                    )
4710
                ) is not None:
4711
                    if project_config.get_feature(feature_.HANDLE) is None:
4712
                        return Response(
4713
                            content=(
4714
                                f"The {feature_.HANDLE} feature is not "
4715
                                f"activated in the project config."
4716
                            ),
4717
                            status_code=HTTP_STATUS_PRECONDITION_FAILED,
4718
                        )
4719
                    feature_.render_screen(
4720
                        FeatureContext(
4721
                            project_config=project_config,
4722
                            traceability_index=export_action.traceability_index,
4723
                            html_templates=html_templates,
4724
                        )
4725
                    )
4726
                else:
4727
                    document_type_to_generate: DocumentType
4728
                    if document_relative_path.relative_path.endswith(
4729
                        "-TABLE.html"
4730
                    ):
4731
                        base_document_url = (
4732
                            document_relative_path.relative_path.replace(
4733
                                "-TABLE", ""
4734
                            )
4735
                        )
4736
                        document_type_to_generate = DocumentType.TABLE
4737
                    elif document_relative_path.relative_path.endswith(
4738
                        "-DEEP-TRACE.html"
4739
                    ):
4740
                        base_document_url = (
4741
                            document_relative_path.relative_path.replace(
4742
                                "-DEEP-TRACE", ""
4743
                            )
4744
                        )
4745
                        document_type_to_generate = DocumentType.DEEPTRACE
4746
                    elif document_relative_path.relative_path.endswith(
4747
                        "-TRACE.html"
4748
                    ):
4749
                        base_document_url = (
4750
                            document_relative_path.relative_path.replace(
4751
                                "-TRACE", ""
4752
                            )
4753
                        )
4754
                        document_type_to_generate = DocumentType.TRACE
4755
                    elif document_relative_path.relative_path.endswith(
4756
                        "-PDF.html"
4757
                    ):
4758
                        if not project_config.is_activated_html2pdf():
4759
                            return Response(
4760
                                content="The HTML2PDF feature is not activated in the project config.",
4761
                                status_code=HTTP_STATUS_PRECONDITION_FAILED,
4762
                            )
4763
                        base_document_url = (
4764
                            document_relative_path.relative_path.replace(
4765
                                "-PDF", ""
4766
                            )
4767
                        )
4768
                        document_type_to_generate = DocumentType.PDF
4769
                    else:
4770
                        # Either this is a normal document, or the path is broken.
4771
                        base_document_url = document_relative_path.relative_path
4772
                        document_type_to_generate = DocumentType.DOCUMENT
4773
 
4774
                    document_tree = assert_cast(
4775
                        export_action.traceability_index.document_tree,
4776
                        DocumentTree,
4777
                    )
4778
                    document = document_tree.map_docs_by_rel_paths.get(
4779
                        base_document_url
4780
                    )
4781
                    if document is None:
4782
                        return _error_response(HTTP_STATUS_NOT_FOUND)
4783
 
4784
                    assert document.meta is not None
4785
                    set_file_modification_time(
4786
                        document.meta.input_doc_full_path,
4787
                        datetime.datetime.today(),
4788
                    )
4789
 
4790
                    html_generator.export_single_document_with_performance(
4791
                        document=document,
4792
                        traceability_index=export_action.traceability_index,
4793
                        specific_documents=(document_type_to_generate,),
4794
                    )
4795
                return None
4796
 
4797
            response_or_none = generate_document()
4798
            if response_or_none is not None:
4799
                return response_or_none
4800
            return FileResponse(
4801
                full_path_to_document,
4802
                media_type="text/html",
4803
                headers={"Cache-Control": "no-cache"},
4804
            )
4805
 
4806
    def get_asset(request: Request, url_to_asset: str) -> Response:
4807
        project_output_path = project_config.export_output_html_root
4808
 
4809
        static_file = os.path.join(project_output_path, url_to_asset)
4810
        content_type, _ = guess_type(static_file)
4811
 
4812
        with lock_manager.acquire_global_read():
4813
            # We keep a global read lock here because this endpoint serves not
4814
            # only bundled immutable static files, but also generated assets
4815
            # under export_output_html_root that may be rewritten at runtime.
4816
            # FIXME: Revisit when asset writes are fully atomic and immutable
4817
            # from the reader's perspective, so this lock can potentially be
4818
            # narrowed or removed.
4819
            if not os.path.isfile(static_file):
4820
                return _error_response(HTTP_STATUS_NOT_FOUND, path_type="asset")
4821
 
4822
            if request_is_for_non_modified_file(request, static_file):
4823
                return Response(status_code=304)
4824
 
4825
            response = FileResponse(static_file, media_type=content_type)
4826
            return response
4827
 
4828
    def _compute_document_mid_lock_key(document_mid: str) -> str:
4829
        return f"document:{document_mid}"
4830
 
4831
    def _compute_document_relative_path_lock_key(relative_path: str) -> str:
4832
        return f"document:{relative_path}"
4833
 
4834
    def _compute_document_generation_lock_key(relative_path: str) -> str:
4835
        return _compute_document_relative_path_lock_key(relative_path)
4836
 
4837
    def _error_response(
4838
        error_code: int, path_type: str = "document"
4839
    ) -> Response:
4840
        view_object = ServerErrorViewObject(
4841
            project_config=project_config,
4842
            error_code=error_code,
4843
            path_type=path_type,
4844
        )
4845
        return Response(
4846
            content=view_object.render_screen(env()),
4847
            status_code=error_code,
4848
            media_type="text/html",
4849
        )
4850
 
4851
    # Websockets solution based on:
4852
    # https://fastapi.tiangolo.com/advanced/websockets/
4853
    class ConnectionManager:
4854
        def __init__(self) -> None:
4855
            self.active_connections: List[WebSocket] = []
4856
            self._lock = asyncio.Lock()
4857
 
4858
        async def connect(self, websocket: WebSocket) -> None:
4859
            await websocket.accept()
4860
            async with self._lock:
4861
                self.active_connections.append(websocket)
4862
 
4863
        async def disconnect(self, websocket: WebSocket) -> None:
4864
            async with self._lock:
4865
                if websocket in self.active_connections:
4866
                    self.active_connections.remove(websocket)
4867
 
4868
        async def broadcast(self, message: str) -> None:
4869
            async with self._lock:
4870
                connections = list(self.active_connections)
4871
            for connection in connections:
4872
                await connection.send_text(message)
4873
 
4874
    manager = ConnectionManager()
4875
 
4876
    def rebuild_index_after_file_change() -> Optional[str]:
4877
        try:
4878
            with lock_manager.acquire_global_write():
4879
                export_action.traceability_index = (
4880
                    TraceabilityIndexBuilder.create(
4881
                        project_config=project_config,
4882
                        parallelizer=parallelizer,
4883
                    )
4884
                )
4885
            return None
4886
        except DocumentTreeError as document_tree_error:
4887
            return document_tree_error.to_print_message()
4888
        except Exception as build_error:  # noqa: BLE001
4889
            return str(build_error)
4890
 
4891
    def notify_clients_after_file_change() -> None:
4892
        build_error = rebuild_index_after_file_change()
4893
        message = "reload" if build_error is None else f"error:{build_error}"
4894
        if build_error is not None:
4895
            print(f"WATCH:    rebuild failed:\n{build_error}")  # noqa: T201
4896
        event_loop = getattr(app.state, "event_loop", None)
4897
        if event_loop is not None:
4898
            asyncio.run_coroutine_threadsafe(
4899
                manager.broadcast(message), event_loop
4900
            )
4901
 
4902
    if project_config.watch_enabled:
4903
        app.state.document_watcher = DocumentWatcher(
4904
            watch_paths=project_config.input_paths or [],
4905
            output_dir_abs_path=project_config.output_dir,
4906
            on_documents_changed=notify_clients_after_file_change,
4907
            watched_extensions=get_watched_document_extensions(project_config),
4908
        )
4909
 
4910
    @router.websocket("/ws/{client_id}")
4911
    async def websocket_endpoint(websocket: WebSocket, client_id: int) -> None:
4912
        await manager.connect(websocket)
4913
        try:
4914
            while True:
4915
                _ = await websocket.receive_text()
4916
                # Do nothing for now.
4917
        except WebSocketDisconnect:
4918
            await manager.disconnect(websocket)
4919
            await manager.broadcast(
4920
                f"Websocket: Client #{client_id} disconnected"
4921
            )
4922
 
4923
    return router