StrictDoc Documentation
strictdoc/export/html/generators/view_objects/document_screen_view_object.py
Source file coverage
Path:
strictdoc/export/html/generators/view_objects/document_screen_view_object.py
Lines:
950
Non-empty lines:
842
Non-empty lines covered with requirements:
842 / 842 (100.0%)
Functions:
65
Functions covered by requirements:
65 / 65 (100.0%)
1
"""
2
@relation(SDOC-SRS-54, scope=file)
3
"""
4
 
5
from dataclasses import dataclass
6
from datetime import datetime
7
from enum import Enum
8
from typing import (
9
    Any,
10
    Dict,
11
    Generator,
12
    Iterator,
13
    List,
14
    Optional,
15
    Sequence,
16
    Set,
17
    Tuple,
18
    Union,
19
)
20
 
21
from jinja2 import Template
22
from markupsafe import Markup, escape
23
 
24
from strictdoc import __version__
25
from strictdoc.backend.sdoc.free_text_reader import SDFreeTextReader
26
from strictdoc.backend.sdoc.models.anchor import Anchor
27
from strictdoc.backend.sdoc.models.document import SDocDocument
28
from strictdoc.backend.sdoc.models.document_view import ViewElement
29
from strictdoc.backend.sdoc.models.grammar_element import (
30
    GrammarElement,
31
    GrammarElementFieldMultipleChoice,
32
    GrammarElementFieldSingleChoice,
33
    GrammarElementFieldTag,
34
)
35
from strictdoc.backend.sdoc.models.inline_link import InlineLink
36
from strictdoc.backend.sdoc.models.model import (
37
    RequirementFieldName,
38
    SDocDocumentIF,
39
    SDocElementIF,
40
    SDocNodeIF,
41
)
42
from strictdoc.backend.sdoc.models.node import SDocNode, SDocNodeField
43
from strictdoc.core.document_iterator import DocumentIterationContext
44
from strictdoc.core.document_tree import DocumentTree
45
from strictdoc.core.document_tree_iterator import DocumentTreeIterator
46
from strictdoc.core.file_system.file_tree import File, FileOrFolderEntry, Folder
47
from strictdoc.core.project_config import ProjectConfig
48
from strictdoc.core.traceability_index import TraceabilityIndex
49
from strictdoc.export.html.document_type import DocumentType
50
from strictdoc.export.html.generators.view_objects.document_chunks import (
51
    CHUNK_SIZE,
52
    DocumentChunk,
53
    slice_chunks,
54
)
55
from strictdoc.export.html.generators.view_objects.helpers import (
56
    screen_should_display_file,
57
    screen_should_display_folder,
58
)
59
from strictdoc.export.html.html_templates import HTMLTemplates, JinjaEnvironment
60
from strictdoc.export.html.renderers.html_fragment_writer import (
61
    HTMLFragmentWriter,
62
)
63
from strictdoc.export.html.renderers.link_renderer import LinkRenderer
64
from strictdoc.export.html.renderers.markup_renderer import MarkupRenderer
65
from strictdoc.helpers.cast import assert_cast
66
from strictdoc.helpers.file_system import file_open_read_utf8
67
from strictdoc.helpers.git_client import GitClient
68
from strictdoc.helpers.string import interpolate_at_pattern_lazy
69
from strictdoc.server.helpers.turbo import render_turbo_stream
70
 
71
 
72
class TableCellEditMode(str, Enum):
73
    AUTOCOMPLETE = "autocomplete"
74
    SINGLELINE = "singleline"
75
    MULTILINE = "multiline"
76
    READONLY = "readonly"
77
 
78
 
79
@dataclass
80
class DocumentScreenViewObject:
81
    def __init__(
82
        self,
83
        *,
84
        document_type: DocumentType,
85
        document: SDocDocument,
86
        traceability_index: TraceabilityIndex,
87
        project_config: ProjectConfig,
88
        link_renderer: LinkRenderer,
89
        markup_renderer: MarkupRenderer,
90
        jinja_environment: JinjaEnvironment,
91
        git_client: GitClient,
92
    ):
93
        self.document_type: DocumentType = document_type
94
        self.link_document_type: DocumentType = DocumentType.DOCUMENT
95
        self.document: SDocDocument = document
96
        self.traceability_index: TraceabilityIndex = traceability_index
97
        self.project_config: ProjectConfig = project_config
98
        self.link_renderer: LinkRenderer = link_renderer
99
        self.markup_renderer: MarkupRenderer = markup_renderer
100
        self.jinja_environment: JinjaEnvironment = jinja_environment
101
        self.git_client: GitClient = git_client
102
        self.document_iterator = self.traceability_index.get_document_iterator(
103
            self.document
104
        )
105
        self.document_tree_iterator: DocumentTreeIterator = (
106
            DocumentTreeIterator(
107
                assert_cast(traceability_index.document_tree, DocumentTree)
108
            )
109
        )
110
        self.current_view: ViewElement = document.view.get_current_view(
111
            project_config.view
112
        )
113
        self.is_running_on_server: bool = project_config.is_running_on_server
114
        self.strictdoc_version = __version__
115
        self._chunked_rendering: Optional[bool] = None
116
        self._chunk_index_by_mid: Optional[Dict[str, int]] = None
117
 
118
        self.custom_html2pdf_template: Optional[Template] = None
119
        if project_config.html2pdf_template is not None:
120
            with file_open_read_utf8(project_config.html2pdf_template) as f_:
121
                self.custom_html2pdf_template = Template(f_.read())
122
 
123
    def has_included_document(self) -> bool:
124
        return len(self.document.included_documents) > 0
125
 
126
    def render_screen(self) -> Markup:
127
        if self.document_type.is_document():
128
            if self.document.config.layout == "Website":
129
                return self.jinja_environment.render_template_as_markup(
130
                    "website/document/index.jinja", view_object=self
131
                )
132
            return self.jinja_environment.render_template_as_markup(
133
                "screens/document/document/index.jinja", view_object=self
134
            )
135
        elif self.document_type.is_table():
136
            return self.jinja_environment.render_template_as_markup(
137
                "screens/document/table/index.jinja", view_object=self
138
            )
139
        elif self.document_type.is_trace():
140
            return self.jinja_environment.render_template_as_markup(
141
                "features/trace/index.jinja", view_object=self
142
            )
143
        elif self.document_type.is_deeptrace():
144
            return self.jinja_environment.render_template_as_markup(
145
                "features/deep_trace/index.jinja",
146
                view_object=self,
147
            )
148
        elif self.document_type.is_pdf():
149
            return self.jinja_environment.render_template_as_markup(
150
                "features/html2pdf/index.jinja", view_object=self
151
            )
152
        else:
153
            raise NotImplementedError(self.document_type)  # pragma: no cover
154
 
155
    def render_updated_screen(self) -> Markup:
156
        output = self.jinja_environment.render_template_as_markup(
157
            "actions/"
158
            "document/"
159
            "create_requirement/"
160
            "stream_created_requirement.jinja.html",
161
            view_object=self,
162
        )
163
 
164
        output += self.jinja_environment.render_template_as_markup(
165
            "actions/document/_shared/stream_updated_toc.jinja.html",
166
            view_object=self,
167
        )
168
 
169
        output += self.jinja_environment.render_template_as_markup(
170
            "actions/document/_shared/stream_updated_viewtype_menu.jinja.html",
171
            view_object=self,
172
        )
173
 
174
        return output
175
 
176
    def render_updated_nodes_and_toc(
177
        self,
178
        nodes: Sequence[Union[SDocDocument, SDocNode]],
179
        node_updated: bool = False,
180
    ) -> str:
181
        output: str = ""
182
 
183
        if node_updated:
184
            # The TOC is rendered before the individual nodes intentionally:
185
            # toc.jinja calls table_of_contents() -> all_content(), which
186
            # iterates every node and writes the correct title_number_string
187
            # into each node's context as a side effect. The node templates
188
            # rendered below then read those values and display the right
189
            # section numbers. Reversing this order would cause nodes whose
190
            # level changed (e.g. a title was added or removed) to render
191
            # with stale numbers.
192
            #
193
            # FIXME: This is a bit hacky. A cleaner solution would be to
194
            # separate the calculation of title_number_string from the rendering
195
            # of the TOC, so that the side effect is explicit and not tied to
196
            # the TOC template.
197
            toc_content = self.jinja_environment.render_template_as_markup(
198
                "screens/document/_shared/toc.jinja", view_object=self
199
            )
200
            output += render_turbo_stream(
201
                content=toc_content,
202
                action="update",
203
                target="frame-toc",
204
            )
205
 
206
            viewtype_menu_content = (
207
                self.jinja_environment.render_template_as_markup(
208
                    "screens/document/_shared/viewtype_menu.jinja",
209
                    view_object=self,
210
                )
211
            )
212
            output += render_turbo_stream(
213
                content=viewtype_menu_content,
214
                action="update",
215
                target="frame-viewtype-menu",
216
            )
217
 
218
        for node_ in nodes:
219
            template_folder: str
220
            if isinstance(node_, SDocDocument):
221
                template_folder = "section"
222
            elif isinstance(node_, SDocNode):
223
                if node_.is_text_node():
224
                    template_folder = "text_node"
225
                else:
226
                    template_folder = "node_content"
227
            else:
228
                raise NotImplementedError
229
            content = self.jinja_environment.render_template_as_markup(
230
                f"components/{template_folder}/index_extends_node.jinja",
231
                view_object=self,
232
                node=node_,
233
            )
234
            output += render_turbo_stream(
235
                content=content,
236
                action="replace",
237
                target=f"article-{node_.reserved_mid}",
238
            )
239
 
240
        return output
241
 
242
    def render_update_document_content_with_moved_node(
243
        self, moved_node: Any
244
    ) -> Markup:
245
        content = self.jinja_environment.render_template_as_markup(
246
            "screens/document/document/frame_document_content.jinja.html",
247
            view_object=self,
248
        )
249
        output = render_turbo_stream(
250
            content=content,
251
            action="replace",
252
            target="frame_document_content",
253
        )
254
        toc_content = self.jinja_environment.render_template_as_markup(
255
            "actions/document/_shared/stream_updated_toc.jinja.html",
256
            view_object=self,
257
            last_moved_node_id=moved_node.reserved_mid,
258
        )
259
        output += render_turbo_stream(
260
            toc_content,
261
            action="update",
262
            target="frame-toc",
263
        )
264
 
265
        output += self.jinja_environment.render_template_as_markup(
266
            "actions/document/_shared/stream_updated_viewtype_menu.jinja.html",
267
            view_object=self,
268
        )
269
 
270
        return output
271
 
272
    def render_document_version(
273
        self, included_document: Optional[SDocDocument] = None
274
    ) -> Optional[str]:
275
        # 'document' is the main view document or an included document
276
        # (e.g., a bundle PDF member); defaults to the main view document.
277
        document_ = (
278
            included_document
279
            if included_document is not None
280
            else self.document
281
        )
282
        if document_.config.version is None:
283
            return None
284
 
285
        def resolver(variable_name: str) -> str:
286
            if variable_name == "GIT_VERSION":
287
                return self.git_client.get_commit_hash()
288
            elif variable_name == "GIT_BRANCH":
289
                return self.git_client.get_branch()
290
            return variable_name
291
 
292
        return interpolate_at_pattern_lazy(document_.config.version, resolver)
293
 
294
    def render_document_date(
295
        self, included_document: Optional[SDocDocument] = None
296
    ) -> Optional[str]:
297
        # 'document' is the main view document or an included document
298
        # (e.g., a bundle PDF member); defaults to the main view document.
299
        document_ = (
300
            included_document
301
            if included_document is not None
302
            else self.document
303
        )
304
        if document_.config.date is None:
305
            return None
306
 
307
        def resolver(variable_name: str) -> str:
308
            if variable_name == "GIT_COMMIT_DATE":
309
                return self.git_client.get_commit_date()
310
            elif variable_name == "GIT_COMMIT_DATETIME":
311
                return self.git_client.get_commit_datetime()
312
            return variable_name
313
 
314
        return interpolate_at_pattern_lazy(document_.config.date, resolver)
315
 
316
    def render_metadata_value(self, metadata_value: str) -> Markup:
317
        """
318
        FIXME: Remove duplication of Git-resolvers in this class.
319
        """
320
 
321
        def resolver(variable_name: str) -> str:
322
            if variable_name == "GIT_VERSION":
323
                return self.git_client.get_commit_hash()
324
            elif variable_name == "GIT_BRANCH":
325
                return self.git_client.get_branch()
326
            elif variable_name == "GIT_COMMIT_DATE":
327
                return self.git_client.get_commit_date()
328
            elif variable_name == "GIT_COMMIT_DATETIME":
329
                return self.git_client.get_commit_datetime()
330
            return variable_name
331
 
332
        free_text_container = SDFreeTextReader.read(metadata_value)
333
        output_parts: List[str] = []
334
        for part in free_text_container.parts:
335
            if isinstance(part, str):
336
                interpolated = interpolate_at_pattern_lazy(part, resolver)
337
                output_parts.append(str(escape(interpolated)))
338
            elif isinstance(part, InlineLink):
339
                linkable_node = (
340
                    self.traceability_index.get_linkable_node_by_uid_weak(
341
                        part.link
342
                    )
343
                )
344
                if linkable_node is None:
345
                    # Dangling or not-yet-complete while the user is still
346
                    # typing: degrade gracefully instead of crashing this
347
                    # ad hoc, possibly unsaved render.
348
                    output_parts.append(str(escape(f"[LINK: {part.link}]")))
349
                else:
350
                    href = self.link_renderer.render_node_link(
351
                        linkable_node, self.document, self.document_type
352
                    )
353
                    output_parts.append(
354
                        HTMLFragmentWriter.write_anchor_link(
355
                            linkable_node.get_display_title(), href
356
                        )
357
                    )
358
            else:
359
                # An Anchor can only be produced here by SDFreeTextReader
360
                # parsing a raw, not-yet-saved value in isolation (where
361
                # "[ANCHOR: ...]" happens to sit at the start of the
362
                # string). Persisted METADATA can never contain a real
363
                # Anchor: the main grammar embeds the value right after
364
                # "KEY: ", never at a line start, so it always parses as
365
                # plain text there. Render defensively as literal text.
366
                assert isinstance(part, Anchor)
367
                output_parts.append(str(escape(f"[ANCHOR: {part.value}]")))
368
        return Markup("".join(output_parts))
369
 
370
    def is_empty_tree(self) -> bool:
371
        return self.document_tree_iterator.is_empty_tree()
372
 
373
    def is_deeptrace(self) -> bool:
374
        return self.document_type.is_deeptrace()
375
 
376
    def has_any_nodes(self) -> bool:
377
        return self.document.has_any_nodes()
378
 
379
    def iterator_files_first(self) -> Iterator[FileOrFolderEntry]:
380
        yield from self.document_tree_iterator.iterator_files_first()
381
 
382
    def render_url(self, url: str) -> Markup:
383
        return Markup(self.link_renderer.render_url(url))
384
 
385
    def render_node_link(
386
        self, node: Union[SDocDocument, SDocNode, Anchor]
387
    ) -> str:
388
        assert isinstance(node, (SDocDocument, SDocNode, Anchor)), node
389
        return self.link_renderer.render_node_link(
390
            node, self.document, self.document_type
391
        )
392
 
393
    def render_document_link(
394
        self,
395
        document: SDocDocument,
396
        context_document: SDocDocument,
397
        document_type_string: str,
398
    ) -> str:
399
        assert document is not None, document
400
        return self.link_renderer.render_node_link(
401
            document, context_document, DocumentType(document_type_string)
402
        )
403
 
404
    def render_current_view_document_link(self, document: SDocDocument) -> str:
405
        assert isinstance(document, SDocDocument), document
406
        assert document.meta is not None
407
        assert self.document.meta is not None
408
        return document.meta.get_html_link(
409
            self.document_type,
410
            self.document.meta.level,
411
        )
412
 
413
    def render_static_url(self, url: str) -> Markup:
414
        return Markup(self.link_renderer.render_static_url(url))
415
 
416
    def render_local_anchor(
417
        self, node: Union[Anchor, SDocNode, SDocDocument]
418
    ) -> str:
419
        return self.link_renderer.render_local_anchor(node)
420
 
421
    def render_node_statement(self, node: SDocNode) -> Markup:
422
        return self.markup_renderer.render_node_statement(
423
            self.document_type, node
424
        )
425
 
426
    def render_truncated_node_statement(self, node: SDocNode) -> Markup:
427
        return self.markup_renderer.render_truncated_node_statement(
428
            self.document_type, node
429
        )
430
 
431
    def render_node_rationale(self, node: SDocNode) -> Markup:
432
        return self.markup_renderer.render_node_rationale(
433
            self.document_type, node
434
        )
435
 
436
    def render_node_field(self, node_field: SDocNodeField) -> Markup:
437
        assert isinstance(node_field, SDocNodeField), node_field
438
        return self.markup_renderer.render_node_field(
439
            self.document_type, node_field
440
        )
441
 
442
    def render_issues(
443
        self,
444
        node: Union[SDocNodeIF, SDocDocumentIF],
445
        field: Optional[str] = None,
446
    ) -> str:
447
        issues = self.traceability_index.validation_index.get_issues(
448
            node, field=field
449
        )
450
        if issues is None:
451
            return ""
452
        issues_html = ""
453
        for issue_ in issues:
454
            issue_html = self.jinja_environment.render_template_as_markup(
455
                "components/issue/index.jinja",
456
                issue=issue_,
457
                view_object=self,
458
            )
459
            issues_html += issue_html
460
        return issues_html
461
 
462
    def get_page_title(self) -> str:
463
        return self.document_type.get_page_title()
464
 
465
    def get_document_level(self) -> int:
466
        assert self.document.meta is not None
467
        return self.document.meta.level
468
 
469
    def date_today(self) -> str:
470
        return datetime.today().strftime("%Y-%m-%d")
471
 
472
    def get_document_by_path(self, full_path: str) -> SDocDocument:
473
        return self.traceability_index.document_tree.get_document_by_path(
474
            full_path
475
        )
476
 
477
    def get_grammar_elements(self) -> List[GrammarElement]:
478
        assert self.document.grammar is not None
479
        return self.document.grammar.elements
480
 
481
    def table_of_contents(
482
        self,
483
    ) -> Iterator[Tuple[SDocElementIF, DocumentIterationContext]]:
484
        yield from self.document_iterator.table_of_contents()
485
 
486
    def document_has_any_toc_nodes(self) -> bool:
487
        return any(self.table_of_contents())
488
 
489
    def document_content_iterator(
490
        self,
491
    ) -> Iterator[Tuple[SDocElementIF, DocumentIterationContext]]:
492
        yield from self.document_iterator.all_content(
493
            print_fragments=True,
494
        )
495
 
496
    def is_chunked_rendering(self) -> bool:
497
        """
498
        Chunked mode: DOCUMENT screens for large documents only (both the
499
        FastAPI server and static HTML export - the two differ only in how a
500
        chunk's HTML reaches the browser, see chunk_frame_id_for() /
501
        static_chunk_relative_path()).
502
 
503
        Activates only when the document contains strictly more content nodes
504
        than the lazy_document_loading_threshold option value.
505
 
506
        The result is computed once and memoized because the node counting
507
        requires a full document walk.
508
        """
509
        if self._chunked_rendering is not None:
510
            return self._chunked_rendering
511
 
512
        threshold = self.project_config.lazy_document_loading_threshold
513
        if threshold == 0 or not self.document_type.is_document():
514
            self._chunked_rendering = False
515
        else:
516
            self._chunked_rendering = self._count_content_nodes() > threshold
517
        return self._chunked_rendering
518
 
519
    def document_chunk_size(self) -> int:
520
        """
521
        Effective chunk size used by the chunked rendering templates.
522
 
523
        The chunking threshold caps the chunk size: with a threshold lower
524
        than CHUNK_SIZE, a document that activates chunked rendering must
525
        still be split into more than one chunk.
526
        """
527
        threshold = self.project_config.lazy_document_loading_threshold
528
        if 0 < threshold < CHUNK_SIZE:
529
            return threshold
530
        return CHUNK_SIZE
531
 
532
    def document_content_chunks(
533
        self, chunk_size: Optional[int] = None
534
    ) -> List[DocumentChunk]:
535
        """
536
        Recomputed per request; chunk cursors are node MIDs — see
537
        DocumentChunk.
538
 
539
        When chunk_size is not provided, the effective document chunk size
540
        is used, see document_chunk_size().
541
        """
542
        if chunk_size is None:
543
            chunk_size = self.document_chunk_size()
544
        node_mids = [
545
            assert_cast(node_, (SDocNodeIF, SDocDocumentIF)).reserved_mid
546
            for node_, _ in self.document_content_iterator()
547
        ]
548
        return slice_chunks(node_mids, chunk_size)
549
 
550
    def chunk_frame_id_for(
551
        self, node: Union[SDocNodeIF, SDocDocumentIF]
552
    ) -> str:
553
        """
554
        Frame id of the lazy chunk that contains node, e.g.
555
        "document-chunk-3", or "" when chunked rendering is inactive or the
556
        node is not part of the chunked content (the screen's own document
557
        root renders outside the chunk loop).
558
 
559
        Used by toc.jinja to stamp each TOC link so the deep-link script
560
        (toc_chunk_navigation.js) can force-load the chunk holding a target
561
        before scrolling to it.
562
        """
563
        if not self.is_chunked_rendering():
564
            return ""
565
        index = self._chunk_index_by_node_mid().get(node.reserved_mid)
566
        if index is None:
567
            return ""
568
        return f"document-chunk-{index}"
569
 
570
    def _chunk_index_by_node_mid(self) -> Dict[str, int]:
571
        # Maps each content node's reserved MID to its chunk index using the
572
        # same ordering and effective chunk size as document_content_chunks().
573
        # Built once per request and memoized.
574
        if self._chunk_index_by_mid is None:
575
            chunk_size = self.document_chunk_size()
576
            mapping: Dict[str, int] = {}
577
            for position, (node_, _) in enumerate(
578
                self.document_content_iterator()
579
            ):
580
                node_mid = assert_cast(
581
                    node_, (SDocNodeIF, SDocDocumentIF)
582
                ).reserved_mid
583
                mapping[node_mid] = position // chunk_size
584
            self._chunk_index_by_mid = mapping
585
        return self._chunk_index_by_mid
586
 
587
    def document_chunk_content_iterator(
588
        self, from_node_mid: str, count: int
589
    ) -> Iterator[Tuple[SDocElementIF, DocumentIterationContext]]:
590
        """
591
        Yield up to count (node, context) pairs, starting from the node whose
592
        reserved MID equals from_node_mid.
593
 
594
        The iteration always walks the document from its start because the
595
        per-node title numbering context must build up during the traversal.
596
        The nodes before the cursor node are skipped. If the cursor MID is not
597
        found, e.g., the node was deleted by a concurrent edit, nothing is
598
        yielded, and the caller is expected to render an empty fragment.
599
        Resolving a successor node for a stale cursor is a documented
600
        follow-up.
601
 
602
        ``count`` must be positive; HTTP callers must validate/clamp
603
        untrusted input before calling (the fragment route owns that
604
        validation).
605
        """
606
        assert count > 0, count
607
        cursor_found = False
608
        yielded = 0
609
        for node_, context_ in self.document_content_iterator():
610
            if not cursor_found:
611
                node_mid = assert_cast(
612
                    node_, (SDocNodeIF, SDocDocumentIF)
613
                ).reserved_mid
614
                if node_mid != from_node_mid:
615
                    continue
616
                cursor_found = True
617
            yield node_, context_
618
            yielded += 1
619
            if yielded == count:
620
                return
621
 
622
    def static_chunk_relative_path(self, chunk: DocumentChunk) -> str:
623
        """
624
        Filename of the generated .js file that delivers this chunk's HTML
625
        for static export, e.g. "document-chunk-3.js". Written by
626
        DocumentHTMLGenerator.export next to the document's own HTML output,
627
        and referenced client-side by toc_chunk_navigation.js's static
628
        delivery path (no FastAPI server to fetch a fragment route from).
629
        """
630
        assert self.document.meta is not None
631
        return (
632
            f"{self.document.meta.document_filename_base}"
633
            f"-chunk-{chunk.index}.js"
634
        )
635
 
636
    def static_chunk_key(self, chunk: DocumentChunk) -> str:
637
        """
638
        Key under window.StrictDoc.chunks that the generated .js file for
639
        this chunk assigns its rendered HTML to.
640
        """
641
        assert self.document.meta is not None
642
        return (
643
            f"{self.document.meta.document_filename_base}-chunk-{chunk.index}"
644
        )
645
 
646
    def _count_content_nodes(self) -> int:
647
        # This is a full document walk: no precomputed node count exists on
648
        # the traceability index. It is called once per request thanks to the
649
        # is_chunked_rendering() memoization. Note that the underlying
650
        # iterator re-patches node.context title numbering during the walk:
651
        # an idempotent mutation, the same one the render loop performs.
652
        return sum(1 for _ in self.document_content_iterator())
653
 
654
    def should_display_folder(self, folder: Folder) -> bool:
655
        return screen_should_display_folder(
656
            folder,
657
            self.traceability_index,
658
            self.project_config,
659
            must_only_include_non_included_sdoc=True,
660
        )
661
 
662
    def should_display_file(self, file: File) -> bool:
663
        return screen_should_display_file(
664
            file,
665
            self.traceability_index,
666
            self.project_config,
667
            must_only_include_non_included_sdoc=True,
668
        )
669
 
670
    def should_display_included_documents_for_document(
671
        self, document: SDocDocument
672
    ) -> bool:
673
        return (
674
            self.project_config.export_included_documents
675
            and len(document.included_documents) > 0
676
        )
677
 
678
    def should_display_stable_link(
679
        self, node: Union[SDocDocument, SDocNode]
680
    ) -> bool:
681
        assert isinstance(node, (SDocDocument, SDocNode)), node
682
        return node.reserved_uid is not None
683
 
684
    def get_stable_link(self, node: Union[SDocDocument, SDocNode]) -> str:
685
        """
686
        Get a stable link for a given node.
687
 
688
        An example of a link produced: ../../#SDOC_UG_CONTACT
689
        The copy_to_clipboard.js script consumes this link and
690
        transforms it into a link like: http://127.0.0.1:5111/?a=SDOC_UG_CONTACT.
691
        """
692
 
693
        assert isinstance(node, (SDocDocument, SDocNode)), node
694
        base_url = self.link_renderer.render_url("")
695
        if node.reserved_uid is not None:
696
            return base_url + "#" + node.reserved_uid
697
        if node.reserved_mid is not None and node.mid_permanent:
698
            return base_url + "#" + node.reserved_mid
699
        return base_url
700
 
701
    def get_html2pdf_classes(self, node: SDocNode) -> str:
702
        """
703
        Get CSS classes for html2pdf4doc for a given node.
704
 
705
        html2pdf4doc rules for `Narrative` requirement style:
706
        * If no multi-line content:
707
            the node is not split at all (with or without a title)
708
            -> add .html2pdf4doc-no-break to node.
709
         * ASSUMPTION:
710
            We assume that the number of single-line strings is no greater
711
            than the height of the page
712
        """
713
 
714
        assert isinstance(node, SDocNode), node
715
 
716
        html2pdf4doc_classes = []
717
 
718
        if (
719
            node.node_type
720
            in self.project_config.html2pdf_forced_page_break_nodes
721
        ):
722
            html2pdf4doc_classes.append("sdoc-html2pdf4doc-break-before")
723
 
724
        if not node.has_multiline_fields():
725
            if node.get_requirement_style_mode() == "narrative":
726
                html2pdf4doc_classes.append("html2pdf4doc-no-break")
727
 
728
            # The section that does not break away from its children.
729
            if node.has_child_nodes():
730
                html2pdf4doc_classes.append("html2pdf4doc-no-hanging")
731
 
732
        return " ".join(html2pdf4doc_classes)
733
 
734
    #
735
    # Document Level Actions
736
    #
737
 
738
    def can_edit_document(self, document: SDocDocument) -> bool:
739
        """
740
        Determines if the document's root configuration (title, metadata) can be edited.
741
        """
742
        return self.traceability_index.can_edit_document(document)
743
 
744
    #
745
    # Node Level Actions
746
    #
747
 
748
    def can_edit_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
749
        return self.traceability_index.can_edit_node(node)
750
 
751
    def can_delete_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
752
        return self.traceability_index.can_delete_node(node)
753
 
754
    def can_clone_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
755
        return self.traceability_index.can_clone_node(node)
756
 
757
    def can_add_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
758
        return self.traceability_index.can_add_node(node)
759
 
760
    def can_insert_next_to_node(
761
        self, node: Union[SDocDocument, SDocNode]
762
    ) -> bool:
763
        return self.traceability_index.can_insert_next_to_node(node)
764
 
765
    def get_table_first_editable_field_name(
766
        self, element_type: str
767
    ) -> Optional[str]:
768
        grammar = self.document.grammar
769
        if grammar is None:
770
            return None
771
        element = grammar.elements_by_type.get(element_type)
772
        if element is None:
773
            return None
774
        preferred_fields = (
775
            "TITLE",
776
            "STATEMENT",
777
            "RATIONALE",
778
            "DESCRIPTION",
779
            "CONTENT",
780
            "COMMENT",
781
        )
782
        for field_name in preferred_fields:
783
            if (
784
                field_name in element.field_titles
785
                and self.is_table_cell_editable(element_type, field_name)
786
            ):
787
                return field_name
788
        for field_name in element.field_titles:
789
            if field_name in ("UID", "MID", "LEVEL", "STATUS", "TAGS"):
790
                continue
791
            if self.is_table_cell_editable(element_type, field_name):
792
                return field_name
793
        for field_name in element.field_titles:
794
            if self.is_table_cell_editable(element_type, field_name):
795
                return field_name
796
        return None
797
 
798
    def can_move_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
799
        return self.traceability_index.can_move_node(node)
800
 
801
    # Table editing
802
    #
803
 
804
    def get_table_cell_edit_mode(
805
        self, element_type: str, field_name: str
806
    ) -> TableCellEditMode:
807
        """
808
        Returns the editing mode for a table cell:
809
          AUTOCOMPLETE — SingleChoice / MultipleChoice / Tag field
810
          SINGLELINE   — single-line STRING field (meta fields)
811
          MULTILINE    — multi-line STRING field (STATEMENT, RATIONALE, COMMENT, custom content)
812
          READONLY     — field not declared in grammar for this element type
813
        """
814
        grammar = self.document.grammar
815
        if grammar is None:
816
            return TableCellEditMode.READONLY
817
        element = grammar.elements_by_type.get(element_type)
818
        if element is None:
819
            return TableCellEditMode.READONLY
820
        field = element.fields_map.get(field_name)
821
        if field is None:
822
            return TableCellEditMode.READONLY
823
        if isinstance(
824
            field,
825
            (
826
                GrammarElementFieldSingleChoice,
827
                GrammarElementFieldMultipleChoice,
828
                GrammarElementFieldTag,
829
            ),
830
        ):
831
            return TableCellEditMode.AUTOCOMPLETE
832
        if element.is_field_multiline(field_name):
833
            return TableCellEditMode.MULTILINE
834
        return TableCellEditMode.SINGLELINE
835
 
836
    def is_table_cell_editable(
837
        self, element_type: str, field_name: str
838
    ) -> bool:
839
        """Returns True if the field is declared in grammar and can be edited on TABLE screen."""
840
        if field_name == "RELATIONS":
841
            grammar = self.document.grammar
842
            if grammar is None:
843
                return False
844
            element = grammar.elements_by_type.get(element_type)
845
            return element is not None and len(element.relations) > 0
846
        return (
847
            self.get_table_cell_edit_mode(element_type, field_name)
848
            != TableCellEditMode.READONLY
849
        )
850
 
851
    def is_table_cell_multiple_choice(
852
        self, element_type: str, field_name: str
853
    ) -> bool:
854
        grammar = self.document.grammar
855
        if grammar is None:
856
            return False
857
        element = grammar.elements_by_type.get(element_type)
858
        if element is None:
859
            return False
860
        field = element.fields_map.get(field_name)
861
        if field is None:
862
            return False
863
        return isinstance(
864
            field,
865
            (GrammarElementFieldMultipleChoice, GrammarElementFieldTag),
866
        )
867
 
868
    def enumerate_table_columns(self) -> Generator[str, None, None]:
869
        """
870
        Yields column identifiers for the TABLE screen in display order.
871
 
872
        Only yields columns that exist in at least one grammar element.
873
        Column order:
874
          1. Non-reserved meta fields (before TITLE/STATEMENT)
875
          2. RELATIONS  — if any grammar element has relations
876
          3. TITLE      — if any grammar element has TITLE
877
          4. STATEMENT  — if any grammar element has STATEMENT
878
          5. RATIONALE  — if any grammar element has RATIONALE
879
          6. COMMENT    — if any grammar element has COMMENT
880
          7. Non-reserved content fields (after TITLE/STATEMENT)
881
 
882
        TYPE and LEVEL are not yielded — they are always present and
883
        rendered as fixed first columns in the template.
884
        """
885
        assert self.document.grammar is not None
886
        assert self.document.grammar.elements is not None
887
        seen: Set[str] = set()
888
 
889
        for element in self.document.grammar.elements:
890
            for title in element.enumerate_table_meta_field_titles():
891
                if title not in seen:
892
                    seen.add(title)
893
                    yield title
894
 
895
        if any(element.relations for element in self.document.grammar.elements):
896
            yield "RELATIONS"
897
 
898
        for name in (
899
            RequirementFieldName.TITLE,
900
            RequirementFieldName.STATEMENT,
901
            RequirementFieldName.RATIONALE,
902
            RequirementFieldName.COMMENT,
903
        ):
904
            if any(
905
                name in element.fields_map
906
                for element in self.document.grammar.elements
907
            ):
908
                yield name
909
 
910
        for element in self.document.grammar.elements:
911
            for (
912
                title
913
            ) in element.enumerate_table_non_reserved_content_field_titles():
914
                if title not in seen:
915
                    seen.add(title)
916
                    yield title
917
 
918
    @staticmethod
919
    def create_for_table_screen(
920
        *,
921
        document: SDocDocument,
922
        traceability_index: TraceabilityIndex,
923
        project_config: ProjectConfig,
924
        html_templates: HTMLTemplates,
925
        git_client: GitClient,
926
        jinja_environment: JinjaEnvironment,
927
    ) -> "DocumentScreenViewObject":
928
        assert document.meta is not None
929
        link_renderer = LinkRenderer(
930
            root_path=document.meta.get_root_path_prefix(),
931
            static_path=project_config.dir_for_sdoc_assets,
932
        )
933
        markup_renderer = MarkupRenderer.create(
934
            markup=document.config.get_markup(),
935
            traceability_index=traceability_index,
936
            link_renderer=link_renderer,
937
            html_templates=html_templates,
938
            config=project_config,
939
            context_document=document,
940
        )
941
        return DocumentScreenViewObject(
942
            document_type=DocumentType.TABLE,
943
            document=document,
944
            traceability_index=traceability_index,
945
            project_config=project_config,
946
            link_renderer=link_renderer,
947
            markup_renderer=markup_renderer,
948
            jinja_environment=jinja_environment,
949
            git_client=git_client,
950
        )