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