Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/templatetags/translations.py: 23%
830 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
4from __future__ import annotations
6import json
7import re
8from collections import defaultdict
9from collections.abc import Iterable
10from datetime import date, datetime
11from html import escape as html_escape
12from typing import TYPE_CHECKING
14from django import template
15from django.contrib.auth.models import AnonymousUser
16from django.contrib.humanize.templatetags.humanize import intcomma
17from django.template.loader import render_to_string
18from django.urls import reverse
19from django.utils import timezone
20from django.utils.formats import number_format as django_number_format
21from django.utils.html import escape, format_html, format_html_join, linebreaks, urlize
22from django.utils.safestring import mark_safe
23from django.utils.translation import gettext, gettext_lazy, ngettext, pgettext
24from siphashc import siphash
26from weblate.accounts.avatar import get_user_display
27from weblate.accounts.models import Profile
28from weblate.auth.models import User
29from weblate.checks.models import CHECKS
30from weblate.checks.utils import highlight_string
31from weblate.lang.models import Language
32from weblate.trans.filter import FILTERS, get_filter_choice
33from weblate.trans.forms import FieldDocsMixin
34from weblate.trans.models import (
35 Announcement,
36 Category,
37 Component,
38 ContributorAgreement,
39 Project,
40 Translation,
41 Unit,
42)
43from weblate.trans.models.translation import GhostTranslation
44from weblate.trans.specialchars import get_display_char
45from weblate.trans.util import split_plural, translation_percent
46from weblate.utils.diff import Differ
47from weblate.utils.docs import get_doc_url
48from weblate.utils.hash import hash_to_checksum
49from weblate.utils.html import format_html_join_comma, list_to_tuples
50from weblate.utils.markdown import render_markdown
51from weblate.utils.messages import get_message_kind as get_message_kind_impl
52from weblate.utils.random import get_random_identifier
53from weblate.utils.stats import (
54 BaseStats,
55 CategoryLanguage,
56 GhostCategoryLanguageStats,
57 GhostProjectLanguageStats,
58 ProjectLanguage,
59)
60from weblate.utils.templatetags.icons import icon
61from weblate.utils.views import SORT_CHOICES
63if TYPE_CHECKING: 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true
64 from collections.abc import Generator, Iterable
66 from django import forms
67 from django.db.models import Model, QuerySet
68 from django.template.context import Context
69 from django.utils.safestring import SafeString
70 from django_stubs_ext import StrOrPromise
72 from weblate.metrics.wrapper import MetricsWrapper
73 from weblate.trans.models import (
74 Alert,
75 Change,
76 ComponentList,
77 )
78 from weblate.utils.stats import (
79 GhostStats,
80 )
82register = template.Library()
84SPACE_START = '<span class="hlspace"><span class="space-space">'
85SPACE_NL_START = '<span class="hlspace"><span class="space-nl">'
86SPACE_MIDDLE_1 = "</span>"
87SPACE_MIDDLE_2 = '<span class="space-space">'
88SPACE_END = "</span></span>"
89SPACE_NL_END = "</span></span><br>"
91GLOSSARY_TEMPLATE = """<span class="glossary-term" title="{}">"""
93# This should match whitespace_regex in weblate/static/loader-bootstrap.js
94WHITESPACE_REGEX = (
95 r"(\t|\u00A0|\u00AD|\u1680|\u2000|\u2001|\u2002|\u2003|"
96 r"\u2004|\u2005|\u2006|\u2007|\u2008|\u2009|\u200A|"
97 r"\u202F|\u205F|\u3000)"
98)
99WHITESPACE_RE = re.compile(WHITESPACE_REGEX, re.MULTILINE)
100NEWLINE_RE = re.compile(r"(\r\n|\r|\n)", re.MULTILINE)
101MULTISPACE_RE = re.compile(r"( +| $|^ )", re.MULTILINE)
102ESCAPE_RE = re.compile(r"""['"&<>]""")
103TYPE_MAPPING = {True: "yes", False: "no", None: "unknown"}
104# Mapping of status report flags to names
105NAME_MAPPING = {
106 True: gettext_lazy("Good configuration"),
107 False: gettext_lazy("Bad configuration"),
108 None: gettext_lazy("Possible configuration"),
109}
111FLAG_TEMPLATE = '<span title="{0}" class="{1}">{2}</span>'
113SOURCE_LINK = (
114 '<a href="{0}" target="_blank" rel="noopener noreferrer"'
115 ' class="{2}" dir="ltr" tabindex="-1">{1}</a>'
116)
117HLCHECK = '<span class="hlcheck" data-value="{}"><span class="highlight-number"></span>'
120class Formatter:
121 def __init__(
122 self,
123 idx,
124 value,
125 unit,
126 glossary,
127 diff,
128 search_match,
129 match,
130 whitespace: bool = True,
131 ) -> None:
132 # Inputs
133 self.idx = idx
134 self.cleaned_value = self.value = value
135 self.unit = unit
136 self.glossary = glossary
137 self.diff = diff
138 self.search_match = search_match
139 self.match = match
140 # Tags output
141 self.tags: dict[int, list[str]] = defaultdict(list)
142 self.differ = Differ()
143 self.whitespace = whitespace
145 def parse(self) -> None:
146 if self.unit: 146 ↛ 148line 146 didn't jump to line 148 because the condition on line 146 was always true
147 self.parse_highlight()
148 if self.glossary: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 self.parse_glossary()
150 if self.search_match: 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true
151 self.parse_search()
152 if self.whitespace: 152 ↛ 154line 152 didn't jump to line 154 because the condition on line 152 was always true
153 self.parse_whitespace()
154 if self.diff: 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true
155 self.parse_diff()
157 def parse_diff(self) -> None: # noqa: C901
158 """Highlights diff, including extra whitespace."""
159 diff = self.differ.compare(self.value, self.diff[self.idx])
160 offset = 0
161 for op, data in diff:
162 if op == self.differ.DIFF_DELETE:
163 formatter = Formatter(
164 0,
165 data,
166 self.unit,
167 self.glossary,
168 None,
169 self.search_match,
170 self.match,
171 )
172 formatter.parse()
173 self.tags[offset].append(f"<del>{formatter.format()}</del>")
174 elif op == self.differ.DIFF_INSERT:
175 end = offset + len(data)
176 # Rearrange space highlighting
177 move_space = False
178 start_space = -1
179 if offset in self.tags:
180 for pos, tag in enumerate(self.tags[offset]):
181 if tag == SPACE_MIDDLE_2:
182 self.tags[offset][pos] = SPACE_MIDDLE_1
183 move_space = True
184 break
185 if tag == SPACE_START:
186 start_space = pos
187 break
189 if start_space != -1:
190 self.tags[offset].insert(start_space, "<ins>")
191 last_middle = None
192 for i in range(len(data)):
193 tagoffset = offset + i + 1
194 if tagoffset not in self.tags:
195 continue
196 for pos, tag in enumerate(self.tags[tagoffset]):
197 if tag == SPACE_END:
198 # Whitespace ends within <ins>
199 start_space = -1
200 break
201 if tag == SPACE_MIDDLE_2:
202 last_middle = (tagoffset, pos)
203 if start_space == -1:
204 break
205 if start_space != -1 and last_middle is not None:
206 self.tags[tagoffset][pos] = SPACE_MIDDLE_1
208 else:
209 self.tags[offset].append("<ins>")
210 if move_space:
211 self.tags[offset].append(SPACE_START)
212 self.tags[end].append("</ins>")
213 if start_space != -1:
214 self.tags[end].append(SPACE_START)
216 # Rearange other tags
217 open_tags = 0
218 process = False
219 for i in range(offset, end + 1):
220 remove = []
221 for pos, tag in enumerate(self.tags[i]):
222 if not process:
223 if tag.startswith("<ins"):
224 process = True
225 continue
226 if tag.startswith("</ins>"):
227 break
228 if tag.startswith("<span"):
229 open_tags += 1
230 elif tag.startswith("</span"):
231 if open_tags == 0:
232 # Remove tags spanning over <ins>
233 remove.append(pos)
234 found = None
235 for back in range(offset - 1, 0, -1):
236 for child_pos, child in reversed(
237 list(enumerate(self.tags[back]))
238 ):
239 if child.startswith("<span"):
240 found = child_pos
241 break
242 if found is not None:
243 del self.tags[back][found]
244 break
245 else:
246 open_tags -= 1
247 # Remove closing tags (do this outside the loop)
248 for pos in reversed(remove):
249 del self.tags[i][pos]
251 offset = end
252 elif op == self.differ.DIFF_EQUAL:
253 offset += len(data)
255 def parse_highlight(self) -> None:
256 """Highlights unit placeables."""
257 highlights = highlight_string(self.value, self.unit)
258 cleaned_value = list(self.value)
259 for start, end, content in highlights: 259 ↛ 260line 259 didn't jump to line 260 because the loop on line 259 never started
260 self.tags[start].append(format_html(HLCHECK, content))
261 self.tags[end].insert(0, "</span>")
262 cleaned_value[start:end] = [" "] * (end - start)
264 # Prepare cleaned up value for glossary terms (we do not want to extract those
265 # from format strings)
266 self.cleaned_value = "".join(cleaned_value)
268 @staticmethod
269 def format_terms(terms):
270 forbidden = []
271 nontranslatable = []
272 translations = []
273 for term in terms:
274 flags = term.all_flags
275 target = html_escape(term.target)
276 source = html_escape(term.source)
277 # Translators: Glossary term formatting used in a tooltip
278 formatted = pgettext("glossary term", "{target} [{source}]").format(
279 source=source, target=target
280 )
281 if "read-only" in flags:
282 nontranslatable.append(source)
283 elif not target:
284 continue
285 elif "forbidden" in flags:
286 forbidden.append(formatted)
287 else:
288 translations.append(formatted)
290 output = []
291 if forbidden:
292 output.append(
293 "\n".join(
294 (
295 ngettext(
296 "Forbidden translation:",
297 "Forbidden translations:",
298 len(forbidden),
299 ),
300 *forbidden,
301 )
302 )
303 )
304 if nontranslatable:
305 output.append(
306 "\n".join(
307 (
308 ngettext(
309 "Untranslatable term:",
310 "Untranslatable terms:",
311 len(nontranslatable),
312 ),
313 *nontranslatable,
314 )
315 )
316 )
317 if translations:
318 output.append(
319 "\n".join(
320 (
321 ngettext(
322 "Glossary term:",
323 "Glossary terms:",
324 len(translations),
325 ),
326 *translations,
327 )
328 )
329 )
330 return "\n\n".join(output)
332 def parse_glossary(self) -> None:
333 """Highlights glossary entries."""
334 # Annotate string with glossary terms
335 locations = defaultdict(list)
336 for term in self.glossary:
337 for start, end in term.glossary_positions:
338 # Skip terms whose parts belong to placeholders
339 if self.cleaned_value[start:end].lower() != term.source.lower():
340 continue
342 for i in range(start, end):
343 locations[i].append(term)
344 locations[end].extend([])
346 # Render span tags for each glossary term match
347 last_entries: list[str] = []
348 for position, entries in sorted(locations.items()):
349 if last_entries and entries != last_entries:
350 self.tags[position].insert(0, "</span>")
352 if entries and entries != last_entries:
353 self.tags[position].append(
354 GLOSSARY_TEMPLATE.format(self.format_terms(entries))
355 )
356 last_entries = entries
358 def parse_search(self) -> None:
359 """Highlights search matches."""
360 tag = self.match
361 if self.match == "search":
362 tag = "hlmatch"
364 start_tag = format_html('<span class="{}">', tag)
365 end_tag = "</span>"
367 for match in re.finditer(
368 re.escape(self.search_match), self.value, flags=re.IGNORECASE
369 ):
370 self.tags[match.start()].append(start_tag)
371 self.tags[match.end()].insert(0, end_tag)
373 def parse_whitespace(self) -> None:
374 """Highlight whitespaces."""
375 value = self.value
377 for match in NEWLINE_RE.finditer(value): 377 ↛ 378line 377 didn't jump to line 378 because the loop on line 377 never started
378 self.tags[match.start()].append(SPACE_NL_START)
379 self.tags[match.end()].insert(0, SPACE_NL_END)
381 for match in MULTISPACE_RE.finditer(value): 381 ↛ 382line 381 didn't jump to line 382 because the loop on line 381 never started
382 self.tags[match.start()].append(SPACE_START)
383 for i in range(match.start() + 1, match.end()):
384 self.tags[i].insert(0, SPACE_MIDDLE_1)
385 self.tags[i].append(SPACE_MIDDLE_2)
386 self.tags[match.end()].insert(0, SPACE_END)
388 for match in WHITESPACE_RE.finditer(value): 388 ↛ 389line 388 didn't jump to line 389 because the loop on line 388 never started
389 whitespace = match.group(0)
390 cls = "space-tab" if whitespace == "\t" else "space-space"
391 title = get_display_char(whitespace)[0]
392 self.tags[match.start()].append(
393 format_html(
394 '<span class="hlspace"><span class="{}" title="{}">', cls, title
395 )
396 )
397 self.tags[match.end()].insert(0, "</span></span>")
399 def format_generator(self) -> Generator[str]:
400 tags = self.tags
401 value = self.value
402 current: list[str]
403 replacements: dict[int, str] = {}
405 # Extract tag positions
406 positions: set[int] = set(tags.keys())
408 # Avoid processing trailing tags in the loop
409 positions.discard(len(value))
411 # Replace special characters "&", "<" and ">" to HTML-safe sequences.
412 # This is like html.escape but inline
413 for match in ESCAPE_RE.finditer(value): 413 ↛ 414line 413 didn't jump to line 414 because the loop on line 413 never started
414 position = match.start()
415 positions.add(position)
416 char = match.group()
417 if char == "&":
418 next_output = "&"
419 elif char == "<":
420 next_output = "<"
421 elif char == ">":
422 next_output = ">"
423 elif char == '"':
424 next_output = """
425 elif char == "'":
426 next_output = "'"
427 else:
428 raise ValueError(char)
429 replacements[position] = next_output
431 previous_start = 0
432 for pos in sorted(positions): 432 ↛ 434line 432 didn't jump to line 434 because the loop on line 432 never started
433 # String up to current position
434 yield value[previous_start:pos]
436 if pos in tags:
437 current = tags[pos]
438 # Special case for leading/trailing whitespace char in diff
439 if (
440 current
441 and value[pos] == " "
442 and "<ins>" in current
443 and SPACE_START not in current
444 ):
445 current.append(SPACE_START)
446 tags[pos + 1].insert(0, SPACE_END)
448 elif pos + 1 in tags:
449 next_tags = tags[pos + 1]
450 if (
451 next_tags
452 and value[pos] == " "
453 and "</ins>" in next_tags
454 and SPACE_END not in next_tags
455 and SPACE_MIDDLE_1 not in next_tags
456 ):
457 current.append(SPACE_START)
458 next_tags.insert(0, SPACE_END)
460 # Tags
461 yield from current
463 if pos in replacements:
464 # HTML escaped string
465 yield replacements[pos]
466 previous_start = pos + 1
467 else:
468 previous_start = pos
470 yield value[previous_start:]
472 # Trailing tags
473 yield from tags[len(value)]
475 def format(self):
476 return mark_safe("".join(self.format_generator())) # noqa: S308
479@register.inclusion_tag("snippets/format-translation.html")
480def format_unit_target(
481 unit,
482 *,
483 value: str | None = None,
484 diff=None,
485 search_match: str | None = None,
486 match: str = "search",
487 simple: bool = False,
488 wrap: bool = False,
489 show_copy: bool = False,
490):
491 return format_translation(
492 plurals=unit.get_target_plurals() if value is None else split_plural(value),
493 language=unit.translation.language,
494 plural=unit.translation.plural,
495 unit=unit,
496 diff=diff,
497 search_match=search_match,
498 match=match,
499 simple=simple,
500 wrap=wrap,
501 show_copy=show_copy,
502 )
505@register.inclusion_tag("snippets/format-translation.html")
506def format_unit_source(
507 unit,
508 *,
509 value: str | None = None,
510 diff=None,
511 search_match: str | None = None,
512 match: str = "search",
513 simple: bool = False,
514 glossary=None,
515 wrap: bool = False,
516 show_copy: bool = False,
517):
518 source_translation = unit.translation.component.source_translation
519 return format_translation(
520 plurals=unit.get_source_plurals() if value is None else split_plural(value),
521 language=source_translation.language,
522 plural=source_translation.plural,
523 unit=unit,
524 diff=diff,
525 search_match=search_match,
526 match=match,
527 simple=simple,
528 glossary=glossary,
529 wrap=wrap,
530 show_copy=show_copy,
531 )
534@register.inclusion_tag("snippets/format-translation.html")
535def format_source_string(
536 value: str,
537 unit,
538 *,
539 search_match: str | None = None,
540 match: str = "search",
541 simple: bool = False,
542 glossary=None,
543 wrap: bool = False,
544 whitespace: bool = True,
545):
546 """Format simple string as in the unit source language."""
547 return format_translation(
548 plurals=[value],
549 language=unit.translation.component.source_language,
550 plural=unit.translation.plural,
551 search_match=search_match,
552 match=match,
553 simple=simple,
554 wrap=wrap,
555 whitespace=whitespace,
556 )
559@register.inclusion_tag("snippets/format-translation.html")
560def format_language_string(
561 value: str,
562 translation,
563 *,
564 diff=None,
565):
566 """Format simple string as in the language."""
567 return format_translation(
568 plurals=split_plural(value),
569 language=translation.language,
570 plural=translation.plural,
571 diff=diff,
572 )
575def format_translation(
576 plurals: list[str],
577 language=None,
578 *,
579 plural=None,
580 diff=None,
581 search_match: str | None = None,
582 simple: bool = False,
583 wrap: bool = False,
584 unit=None,
585 match: str = "search",
586 glossary=None,
587 whitespace: bool = True,
588 show_copy: bool = False,
589):
590 """Nicely formats translation text possibly handling plurals or diff."""
591 is_multivalue = unit is not None and unit.translation.component.is_multivalue
593 if plural is None: 593 ↛ 594line 593 didn't jump to line 594 because the condition on line 593 was never true
594 plural = language.plural
596 # Split diff plurals
597 if diff is not None: 597 ↛ 598line 597 didn't jump to line 598 because the condition on line 597 was never true
598 diff = split_plural(diff)
599 # Previous message did not have to be a plural
600 while len(diff) < len(plurals):
601 diff.append(diff[0])
603 # We will collect part for each plural
604 parts = []
605 has_content = False
607 for idx, text in enumerate(plurals):
608 formatter = Formatter(
609 idx, text, unit, glossary, diff, search_match, match, whitespace=whitespace
610 )
611 formatter.parse()
613 # Show label for plural (if there are any)
614 title = ""
615 if len(plurals) > 1 and not is_multivalue: 615 ↛ 616line 615 didn't jump to line 616 because the condition on line 615 was never true
616 title = plural.get_plural_name(idx)
618 # Join paragraphs
619 content = formatter.format()
621 parts.append(
622 {
623 "title": title,
624 "content": content,
625 "copy": escape(text) if show_copy else "",
626 }
627 )
628 has_content |= bool(content)
630 return {
631 "simple": simple,
632 "wrap": wrap,
633 "items": parts,
634 "language": language,
635 "unit": unit,
636 "has_content": has_content,
637 }
640@register.simple_tag
641def search_name(query):
642 """Return name for a query string."""
643 return FILTERS.get_search_name(query)
646@register.simple_tag
647def check_name(check):
648 """Return check name, or its id if check is not known."""
649 try:
650 return escape(CHECKS[check].name)
651 except KeyError:
652 return escape(check)
655@register.simple_tag
656def check_description(check):
657 """Return check description, or its id if check is not known."""
658 try:
659 return escape(CHECKS[check].description)
660 except KeyError:
661 return escape(check)
664@register.simple_tag(takes_context=True)
665def documentation(context: Context, page, anchor=""):
666 """Return link to Weblate documentation."""
667 # User might not be present on error pages
668 user = context.get("user")
669 # Use object method get_doc_url if present
670 if hasattr(page, "get_doc_url"): 670 ↛ 671line 670 didn't jump to line 671 because the condition on line 670 was never true
671 return page.get_doc_url(user=user)
672 return get_doc_url(page, anchor, user=user)
675def render_documentation_icon(doc_url: str | None, *, right: bool = False) -> str:
676 if not doc_url:
677 return ""
678 return format_html(
679 """<a class="{} doc-link" href="{}" title="{}" target="_blank" rel="noopener" tabindex="-1">{}</a>""",
680 "pull-right flip" if right else "",
681 doc_url,
682 gettext("Documentation"),
683 icon("info.svg"),
684 )
687def render_documentation_icon5(doc_url: str, *, right: bool = False):
688 if not doc_url:
689 return ""
690 return format_html(
691 """<a class="{} doc-link" href="{}" title="{}" target="_blank" rel="noopener" tabindex="-1">{}</a>""",
692 "float-end" if right else "",
693 doc_url,
694 gettext("Documentation"),
695 icon("info.svg"),
696 )
699@register.simple_tag(takes_context=True)
700def documentation_icon(
701 context: Context, page: str, anchor: str = "", right: bool = False
702) -> str:
703 return render_documentation_icon(documentation(context, page, anchor), right=right)
706@register.simple_tag(takes_context=True)
707def documentation_icon5(
708 context: Context, page: str, anchor: str = "", right: bool = False
709):
710 return render_documentation_icon5(documentation(context, page, anchor), right=right)
713@register.simple_tag(takes_context=True)
714def form_field_doc_link(context: Context, form: forms.Form, field: forms.Field) -> str:
715 if isinstance(form, FieldDocsMixin) and (field_doc := form.get_field_doc(field)):
716 return render_documentation_icon(get_doc_url(*field_doc, user=context["user"]))
717 return ""
720@register.inclusion_tag("message.html")
721def show_message(tags, message):
722 tags = tags.split()
723 final = []
724 task_id = None
725 for tag in tags:
726 if tag.startswith("task:"):
727 task_id = tag[5:]
728 else:
729 final.append(tag)
730 return {"tags": " ".join(final), "task_id": task_id, "message": message}
733def naturaltime_past(value, now):
734 """Convert past dates to natural time."""
735 delta = now - value
737 if delta.days >= 365:
738 count = delta.days // 365
739 if count == 1:
740 return gettext("a year ago")
741 return ngettext("%(count)s year ago", "%(count)s years ago", count) % {
742 "count": count
743 }
744 if delta.days >= 30:
745 count = delta.days // 30
746 if count == 1:
747 return gettext("a month ago")
748 return ngettext("%(count)s month ago", "%(count)s months ago", count) % {
749 "count": count
750 }
751 if delta.days >= 14:
752 count = delta.days // 7
753 return ngettext("%(count)s week ago", "%(count)s weeks ago", count) % {
754 "count": count
755 }
756 if delta.days > 0:
757 if delta.days == 7:
758 return gettext("a week ago")
759 if delta.days == 1:
760 return gettext("yesterday")
761 return ngettext("%(count)s day ago", "%(count)s days ago", delta.days) % {
762 "count": delta.days
763 }
764 if delta.seconds == 0:
765 return gettext("now")
766 if delta.seconds < 60:
767 if delta.seconds == 1:
768 return gettext("a second ago")
769 return ngettext(
770 "%(count)s second ago", "%(count)s seconds ago", delta.seconds
771 ) % {"count": delta.seconds}
772 if delta.seconds // 60 < 60:
773 count = delta.seconds // 60
774 if count == 1:
775 return gettext("a minute ago")
776 return ngettext("%(count)s minute ago", "%(count)s minutes ago", count) % {
777 "count": count
778 }
779 count = delta.seconds // 60 // 60
780 if count == 1:
781 return gettext("an hour ago")
782 return ngettext("%(count)s hour ago", "%(count)s hours ago", count) % {
783 "count": count
784 }
787def naturaltime_future(value, now):
788 """Convert future dates to natural time."""
789 delta = value - now
791 if delta.days >= 365:
792 count = delta.days // 365
793 if count == 1:
794 return gettext("a year from now")
795 return ngettext(
796 "%(count)s year from now", "%(count)s years from now", count
797 ) % {"count": count}
798 if delta.days >= 30:
799 count = delta.days // 30
800 if count == 1:
801 return gettext("a month from now")
802 return ngettext(
803 "%(count)s month from now", "%(count)s months from now", count
804 ) % {"count": count}
805 if delta.days >= 14:
806 count = delta.days // 7
807 return ngettext(
808 "%(count)s week from now", "%(count)s weeks from now", count
809 ) % {"count": count}
810 if delta.days > 0:
811 if delta.days == 1:
812 return gettext("tomorrow")
813 if delta.days == 7:
814 return gettext("a week from now")
815 return ngettext(
816 "%(count)s day from now", "%(count)s days from now", delta.days
817 ) % {"count": delta.days}
818 if delta.seconds == 0:
819 return gettext("now")
820 if delta.seconds < 60:
821 if delta.seconds == 1:
822 return gettext("a second from now")
823 return ngettext(
824 "%(count)s second from now", "%(count)s seconds from now", delta.seconds
825 ) % {"count": delta.seconds}
826 if delta.seconds // 60 < 60:
827 count = delta.seconds // 60
828 if count == 1:
829 return gettext("a minute from now")
830 return ngettext(
831 "%(count)s minute from now", "%(count)s minutes from now", count
832 ) % {"count": count}
833 count = delta.seconds // 60 // 60
834 if count == 1:
835 return gettext("an hour from now")
836 return ngettext("%(count)s hour from now", "%(count)s hours from now", count) % {
837 "count": count
838 }
841@register.filter(is_safe=True)
842def naturaltime(
843 value: float | datetime, microseconds: bool = False, *, now: datetime | None = None
844):
845 """
846 Heavily based on Django's django.contrib.humanize implementation of naturaltime.
848 For date and time values shows how many seconds, minutes or hours ago compared to
849 current timestamp returns representing string.
850 """
851 # float is what time() returns
852 if isinstance(value, float):
853 value = datetime.fromtimestamp(value, tz=timezone.get_current_timezone())
854 # datetime is a subclass of date
855 if not isinstance(value, date):
856 return value
858 # Default to current timestamp
859 if now is None:
860 now = timezone.now()
862 if value < now:
863 text = naturaltime_past(value, now)
864 else:
865 text = naturaltime_future(value, now)
867 # Strip microseconds
868 if isinstance(value, datetime) and not microseconds:
869 value = value.replace(microsecond=0)
871 return format_html(
872 '<span title="{}">{}</span>',
873 timezone.localtime(value).isoformat(),
874 text,
875 )
878def get_stats(obj):
879 if isinstance(obj, BaseStats):
880 return obj
881 return obj.stats
884@register.simple_tag
885def review_percent(obj):
886 stats = get_stats(obj)
887 return list_objects_percent(
888 value=stats.approved + stats.readonly,
889 percent=stats.approved_percent + stats.readonly_percent,
890 query="q=state:>=approved",
891 total=stats.all,
892 checks=stats.allchecks,
893 css="zero-width-540",
894 )
897def translation_progress_render(
898 total: int, readonly: int, approved: int, translated: int, has_review: bool
899) -> StrOrPromise:
900 if has_review:
901 translated -= approved
902 approved += readonly
903 translated -= readonly
905 approved_percent = translation_percent(approved, total, False)
906 good_percent = translation_percent(translated, total)
908 approved_tag = ""
909 good_tag = ""
910 if approved_percent > 0.1:
911 approved_tag = format_html(
912 """
913 <div class="progress progress5"
914 role="progressbar"
915 aria-valuenow="{approved}"
916 aria-valuemin="0"
917 aria-valuemax="100"
918 style="width: {approved}%"
919 title="{title}">
920 <div class="progress-bar"></div>
921 </div>
922 """,
923 approved=f"{approved_percent:.1f}",
924 title=gettext("Approved"),
925 )
926 if good_percent > 0.1:
927 good_tag = format_html(
928 """
929 <div class="progress progress5"
930 role="progressbar"
931 aria-valuenow="{good}"
932 aria-valuemin="0"
933 aria-valuemax="100"
934 style="width: {good}%"
935 title="{title}">
936 <div class="progress-bar progress-bar-success"></div>
937 </div>
938 """,
939 good=f"{good_percent:.1f}",
940 title=gettext("Translated without any problems"),
941 )
943 return format_html(
944 """<div class="progress-stacked progress5" title="{}">{}{}</div>""",
945 gettext("Needs attention"),
946 approved_tag,
947 good_tag,
948 )
951@register.simple_tag
952def translation_progress(obj):
953 stats = get_stats(obj)
954 return translation_progress_render(
955 stats.all,
956 stats.readonly,
957 stats.approved,
958 stats.translated_without_checks,
959 stats.has_review,
960 )
963@register.simple_tag
964def words_progress(obj):
965 stats = get_stats(obj)
966 return translation_progress_render(
967 stats.all_words,
968 stats.readonly_words,
969 stats.approved_words,
970 stats.translated_without_checks_words,
971 stats.has_review,
972 )
975@register.simple_tag
976def chars_progress(obj):
977 stats = get_stats(obj)
978 return translation_progress_render(
979 stats.all_chars,
980 stats.readonly_chars,
981 stats.approved_chars,
982 stats.translated_without_checks_chars,
983 stats.has_review,
984 )
987@register.simple_tag
988def unit_state_class(unit) -> str:
989 """Return state flags."""
990 if unit.has_failing_check:
991 return "unit-state-bad"
992 if not unit.translated:
993 return "unit-state-todo"
994 if unit.approved or (unit.readonly and unit.translation.enable_review):
995 return "unit-state-approved"
996 return "unit-state-translated"
999@register.simple_tag
1000def unit_state_title(unit) -> str:
1001 state = [unit.get_state_display()]
1002 checks = unit.active_checks
1003 if checks:
1004 state.append(
1005 "{} {}".format(
1006 pgettext("String state", "Failing checks:"),
1007 format_html_join_comma(
1008 "{}",
1009 list_to_tuples(checks),
1010 ),
1011 )
1012 )
1013 checks = unit.dismissed_checks
1014 if checks:
1015 state.append(
1016 "{} {}".format(
1017 pgettext("String state", "Dismissed checks:"),
1018 format_html_join_comma("{}", list_to_tuples(checks)),
1019 )
1020 )
1021 if unit.has_comment:
1022 state.append(pgettext("String state", "Commented"))
1023 if unit.has_suggestion:
1024 state.append(pgettext("String state", "Suggested"))
1025 if "forbidden" in unit.all_flags:
1026 state.append(gettext("This translation is forbidden."))
1027 return "; ".join(state)
1030def try_linkify_filename(
1031 text, filename: str, line: str, unit, profile, link_class: str = ""
1032):
1033 """
1034 Attempt to convert `text` to a repo link to `filename:line`.
1036 If the `text` is prefixed with http:// or https://, the
1037 link will be an absolute link to the specified resource.
1038 """
1039 link = None
1040 if re.search(r"^https?://", text):
1041 link = text
1042 elif profile:
1043 link = unit.translation.component.get_repoweb_link(
1044 filename, line, profile.editor_link
1045 )
1046 if link:
1047 return format_html(SOURCE_LINK, link, text, link_class)
1048 return text
1051@register.simple_tag
1052def get_location_links(user: User | None, unit):
1053 """Generate links to source files where translation was used."""
1054 # Fallback to source unit if it has more information
1055 if not unit.location and unit.source_unit.location:
1056 unit = unit.source_unit
1058 # Do we have any locations?
1059 if not unit.location:
1060 return ""
1062 # Is it just an ID?
1063 if unit.location.isdigit():
1064 return gettext("string ID %s") % unit.location
1066 profile = user.profile if user else None
1068 # Go through all locations separated by comma
1069 return format_html_join(
1070 mark_safe('\n<span class="divisor">•</span>\n'),
1071 "{}",
1072 (
1073 (
1074 try_linkify_filename(
1075 location, filename, line, unit, profile, "wrap-text"
1076 ),
1077 )
1078 for location, filename, line in unit.get_locations()
1079 ),
1080 )
1083@register.simple_tag(takes_context=True)
1084def announcements(
1085 context: Context, project=None, component=None, language=None, bootstrap_5=False
1086):
1087 """Display announcement messages for given context."""
1088 user = context["user"]
1090 return format_html_join(
1091 "\n",
1092 "{}",
1093 (
1094 (
1095 render_to_string(
1096 "message.html",
1097 {
1098 "tags": f"{announcement.severity} announcement",
1099 "message": render_markdown(announcement.message),
1100 "announcement": announcement,
1101 "bootstrap_5": bootstrap_5,
1102 "can_delete": user.has_perm(
1103 "meta:announcement.delete", announcement
1104 ),
1105 },
1106 ),
1107 )
1108 for announcement in Announcement.objects.context_filter(
1109 project, component, language
1110 )
1111 ),
1112 )
1115@register.simple_tag(takes_context=True)
1116def active_tab(context: Context, slug):
1117 active = "active" if slug == context["active_tab_slug"] else ""
1118 return format_html('class="tab-pane {}" id="{}"', active, slug)
1121@register.simple_tag(takes_context=True)
1122def active_link(context: Context, slug):
1123 active = "active" if slug == context["active_tab_slug"] else ""
1124 return format_html('class="nav-link {}"', active)
1127def _needs_agreement(component, user: User) -> bool:
1128 if not component.agreement:
1129 return False
1130 return not ContributorAgreement.objects.has_agreed(user, component)
1133@register.simple_tag(takes_context=True)
1134def needs_agreement(context: Context, component):
1135 return _needs_agreement(component, context["user"])
1138@register.simple_tag(takes_context=True)
1139def show_contributor_agreement(context: Context, component):
1140 if not _needs_agreement(component, context["user"]):
1141 return ""
1143 return render_to_string(
1144 "snippets/component/contributor-agreement.html",
1145 {
1146 "object": component,
1147 "next": context["request"].get_full_path(),
1148 "user": context["user"],
1149 },
1150 )
1153@register.simple_tag(takes_context=True)
1154def get_translate_url(context: Context, obj, glossary_browse=True) -> str:
1155 """Get translate URL based on user preference."""
1156 if isinstance(obj, BaseStats) or not hasattr(obj, "get_translate_url"):
1157 return ""
1158 if glossary_browse and hasattr(obj, "component") and obj.component.is_glossary:
1159 name = "browse"
1160 elif context["user"].profile.translate_mode == Profile.TRANSLATE_ZEN:
1161 name = "zen"
1162 else:
1163 name = "translate"
1164 return reverse(name, kwargs={"path": obj.get_url_path()})
1167@register.simple_tag
1168def get_search_url(obj) -> str:
1169 """Get translate URL based on user preference."""
1170 if not hasattr(obj, "get_url_path"):
1171 # Ghost translation
1172 return ""
1173 return reverse("search", kwargs={"path": obj.get_url_path()})
1176@register.simple_tag(takes_context=True)
1177def get_browse_url(context: Context, obj):
1178 """Get translate URL based on user preference."""
1179 # Project listing on language page
1180 if "language" in context and isinstance(obj, Project):
1181 project_language = ProjectLanguage(obj, context["language"])
1182 return project_language.get_absolute_url()
1184 return obj.get_absolute_url()
1187@register.simple_tag(takes_context=True)
1188def init_unique_row_id(context) -> str:
1189 context["row_uuid"] = get_random_identifier()
1190 return ""
1193@register.simple_tag(takes_context=True)
1194def get_unique_row_id(context: Context, obj):
1195 """Get unique row ID for multiline tables."""
1196 return "{}-{}".format(context["row_uuid"], obj.pk)
1199@register.simple_tag
1200def get_filter_name(name: str) -> str:
1201 names = dict(get_filter_choice())
1202 return names[name]
1205def translation_alerts(
1206 translation: Translation | ProjectLanguage | GhostTranslation,
1207) -> Iterable[tuple[str, StrOrPromise, str | None]]:
1208 if translation.is_source:
1209 yield (
1210 "state/source.svg",
1211 gettext("This language is used for source strings."),
1212 None,
1213 )
1216def component_alerts(
1217 component: Component,
1218) -> Iterable[tuple[str, StrOrPromise, str | None]]:
1219 if component.is_repo_link:
1220 yield (
1221 "state/link.svg",
1222 gettext("This component is linked to the %(target)s repository.")
1223 % {"target": component.linked_component},
1224 None,
1225 )
1227 if component.all_active_alerts:
1228 yield (
1229 "state/alert.svg",
1230 gettext("Fix this component to clear its alerts."),
1231 component.get_absolute_url() + "#alerts",
1232 )
1234 if component.locked:
1235 yield ("state/lock.svg", gettext("This translation is locked."), None)
1237 if component.in_progress():
1238 yield (
1239 "state/update.svg",
1240 gettext("Updating translation component…"),
1241 reverse("show_progress", kwargs={"path": component.get_url_path()})
1242 + "?info=1",
1243 )
1246def project_alerts(project: Project) -> Iterable[tuple[str, StrOrPromise, str | None]]:
1247 if project.has_alerts:
1248 yield (
1249 "state/alert.svg",
1250 gettext("Some of the components within this project have alerts."),
1251 None,
1252 )
1254 if project.locked:
1255 yield ("state/lock.svg", gettext("This translation is locked."), None)
1258def get_alerts(
1259 *,
1260 context: Context,
1261 obj: Translation
1262 | Component
1263 | ProjectLanguage
1264 | Project
1265 | GhostProjectLanguageStats
1266 | GhostCategoryLanguageStats,
1267 translation: Translation | GhostTranslation | None,
1268 component: Component | None,
1269 project: Project | None,
1270 project_language: ProjectLanguage | None,
1271) -> Iterable[tuple[str, StrOrPromise, str | None]]:
1272 global_base = context.get("global_base")
1274 if project_language is not None:
1275 # For source language
1276 yield from translation_alerts(project_language)
1278 if project is not None and context["user"].has_perm("project.edit", project):
1279 yield ("state/admin.svg", gettext("You administrate this project."), None)
1281 if translation is not None:
1282 yield from translation_alerts(translation)
1284 if component is not None:
1285 yield from (component_alerts(component))
1286 elif project is not None:
1287 yield from (project_alerts(project))
1289 if getattr(obj, "is_ghost", False):
1290 yield (
1291 ("state/ghost.svg", gettext("This translation does not yet exist."), None)
1292 )
1293 elif global_base:
1294 if isinstance(global_base, str):
1295 global_base = getattr(obj, global_base)
1296 stats = get_stats(obj)
1298 count = global_base.source_strings - stats.all
1299 if count:
1300 yield (
1301 (
1302 "state/ghost.svg",
1303 ngettext(
1304 "%(count)s string is not being translated here.",
1305 "%(count)s strings are not being translated here.",
1306 count,
1307 )
1308 % {"count": intcomma(count)},
1309 None,
1310 )
1311 )
1313 if is_shared := getattr(obj, "is_shared", False):
1314 yield (
1315 (
1316 "state/share.svg",
1317 gettext("Shared from the %s project.") % is_shared,
1318 None,
1319 )
1320 )
1323@register.simple_tag(takes_context=True)
1324def indicate_alerts(
1325 context: Context,
1326 obj: Translation
1327 | Component
1328 | ProjectLanguage
1329 | Project
1330 | GhostProjectLanguageStats
1331 | GhostCategoryLanguageStats,
1332) -> str:
1333 translation: Translation | GhostTranslation | None = None
1334 component: Component | None = None
1335 project: Project | None = None
1336 project_language: ProjectLanguage | None = None
1338 if isinstance(obj, (Translation, GhostTranslation)):
1339 translation = obj
1340 component = obj.component
1341 project = component.project
1342 elif isinstance(obj, Component):
1343 component = obj
1344 project = component.project
1345 elif isinstance(obj, Project):
1346 project = obj
1347 elif isinstance(obj, ProjectLanguage):
1348 project = obj.project
1349 project_language = obj
1350 elif isinstance(obj, GhostProjectLanguageStats):
1351 project = obj.project
1352 elif isinstance(obj, GhostCategoryLanguageStats):
1353 project = obj.category.project
1355 icons = format_html_join(
1356 "\n",
1357 '{}<span class="state-icon {}" title="{}" alt="{}">{}</span>{}',
1358 (
1359 (
1360 format_html('<a href="{}">', url) if url else "",
1361 "grey"
1362 if icon_name == "state/ghost.svg"
1363 else "red"
1364 if icon_name == "state/alert.svg"
1365 else "",
1366 text,
1367 text,
1368 icon(icon_name),
1369 mark_safe("</a>") if url else "",
1370 )
1371 for icon_name, text, url in get_alerts(
1372 context=context,
1373 translation=translation,
1374 component=component,
1375 project=project,
1376 project_language=project_language,
1377 obj=obj,
1378 )
1379 ),
1380 )
1382 license_badge = ""
1383 if component and component.license and component.license != "proprietary":
1384 license_badge = format_html(
1385 ' <span title="{}" class="license badge">{}</span>',
1386 component.get_license_display(),
1387 component.license,
1388 )
1390 return format_html("{}{}", icons, license_badge)
1393@register.filter(is_safe=True)
1394def markdown(text: str) -> str:
1395 return format_html('<div class="markdown">{}</div>', render_markdown(text))
1398@register.filter
1399def format_commit_author(commit) -> str:
1400 users = User.objects.filter(
1401 social_auth__verifiedemail__email=commit["author_email"]
1402 )
1403 user = users.first()
1404 if user is None:
1405 return commit["author_name"]
1406 return get_user_display(user, True, True)
1409@register.filter
1410def percent_format(number: float) -> str:
1411 if number < 0.1:
1412 percent = 0
1413 elif number < 1: 1413 ↛ 1414line 1413 didn't jump to line 1414 because the condition on line 1413 was never true
1414 percent = 1
1415 elif number >= 99.999999: 1415 ↛ 1417line 1415 didn't jump to line 1417 because the condition on line 1415 was always true
1416 percent = 100
1417 elif number > 99:
1418 percent = 99
1419 else:
1420 percent = int(number)
1421 return mark_safe( # noqa: S308
1422 # Translators: Formatting of the translation percent, insert non-breakable space if
1423 # your language expects it before the percent sign.
1424 pgettext("Translated percents", "%(percent)s%%")
1425 % {"percent": intcomma(percent)}
1426 )
1429@register.filter
1430def number_format(number: int) -> str:
1431 format_string = "%s"
1432 if number > 99999999:
1433 number //= 1000000
1434 # Translators: Number format, in millions (mega)
1435 format_string = gettext("%s M")
1436 elif number > 99999:
1437 number //= 1000
1438 # Translators: Number format, in thousands (kilo)
1439 format_string = gettext("%s k")
1440 return format_string % django_number_format(number, force_grouping=True)
1443@register.filter
1444def trend_format(number: int) -> str:
1445 if number < 0:
1446 prefix = "−"
1447 trend = "trend-down"
1448 else:
1449 prefix = "+"
1450 trend = "trend-up"
1451 number = abs(number)
1452 if number < 0.1:
1453 return "—"
1454 return format_html(
1455 '{}{} <span class="{}"></span>',
1456 prefix,
1457 percent_format(number),
1458 trend,
1459 )
1462@register.filter
1463def hash_text(name: str) -> str:
1464 """Hash text for use in HTML id."""
1465 return hash_to_checksum(siphash("Weblate URL hash", name.encode()))
1468@register.simple_tag
1469def sort_choices():
1470 return SORT_CHOICES.items()
1473@register.simple_tag(takes_context=True)
1474def render_alert(context: Context, alert: Alert) -> str:
1475 return alert.render(user=context["user"])
1478@register.simple_tag
1479def get_message_kind(tags) -> str:
1480 return get_message_kind_impl(tags)
1483@register.simple_tag
1484def any_unit_has_context(units: Iterable[Unit]) -> bool:
1485 return any(unit.context for unit in units)
1488@register.filter(is_safe=True, needs_autoescape=True)
1489def urlize_ugc(value: str, autoescape: bool = True) -> str:
1490 """Convert URLs in plain text into clickable links."""
1491 html = urlize(value, nofollow=True, autoescape=autoescape)
1492 return mark_safe( # noqa: S308
1493 html.replace('rel="nofollow"', 'rel="ugc" target="_blank"')
1494 )
1497@register.simple_tag
1498def get_glossary_badge(component: Component | GhostStats) -> StrOrPromise:
1499 if isinstance(component, Component) and component.is_glossary:
1500 return format_html(
1501 '<span class="badge label-{}">{}</span>',
1502 component.glossary_color,
1503 gettext("Glossary"),
1504 )
1505 return ""
1508def get_breadcrumbs( # noqa: C901
1509 path_object, *, flags: bool = True, only_names: bool = False
1510) -> Generator[str | tuple[str, str]]:
1511 def with_url(
1512 name: Model | int | str, url: str | None = None
1513 ) -> str | tuple[str, str]:
1514 if not isinstance(name, str):
1515 name = str(name)
1516 if only_names:
1517 return name
1518 if url is None:
1519 url = path_object.get_absolute_url()
1520 return url, name
1522 if isinstance(path_object, Unit):
1523 yield from get_breadcrumbs(
1524 path_object.translation, flags=flags, only_names=only_names
1525 )
1526 yield with_url(path_object.pk)
1527 elif isinstance(path_object, Translation):
1528 yield from get_breadcrumbs(
1529 path_object.component, flags=flags, only_names=only_names
1530 )
1531 yield with_url(path_object.language)
1532 elif isinstance(path_object, Component):
1533 if path_object.category:
1534 yield from get_breadcrumbs(
1535 path_object.category, flags=flags, only_names=only_names
1536 )
1537 else:
1538 yield from get_breadcrumbs(
1539 path_object.project, flags=flags, only_names=only_names
1540 )
1541 name = path_object.name
1542 if flags:
1543 name = format_html("{}{}", name, get_glossary_badge(path_object))
1544 yield with_url(name)
1545 elif isinstance(path_object, Category):
1546 if path_object.category:
1547 yield from get_breadcrumbs(
1548 path_object.category, flags=flags, only_names=only_names
1549 )
1550 else:
1551 yield from get_breadcrumbs(
1552 path_object.project, flags=flags, only_names=only_names
1553 )
1554 yield with_url(path_object.name)
1555 elif isinstance(path_object, Project):
1556 yield with_url(path_object.name)
1557 elif isinstance(path_object, Language):
1558 yield with_url(gettext("Languages"), url=reverse("languages"))
1559 yield with_url(path_object)
1560 elif isinstance(path_object, ProjectLanguage):
1561 yield (
1562 path_object.project.get_absolute_url(),
1563 path_object.project.name,
1564 )
1565 yield with_url(path_object.language)
1566 elif isinstance(path_object, CategoryLanguage):
1567 if path_object.category.category:
1568 yield from get_breadcrumbs(
1569 path_object.category.category, flags=flags, only_names=only_names
1570 )
1571 else:
1572 yield from get_breadcrumbs(
1573 path_object.category.project, flags=flags, only_names=only_names
1574 )
1575 yield (
1576 path_object.category.get_absolute_url(),
1577 path_object.category.name,
1578 )
1579 yield with_url(path_object.language)
1580 else:
1581 msg = f"No breadcrumbs for {path_object}"
1582 raise TypeError(msg)
1585@register.simple_tag
1586def path_object_breadcrumbs(path_object, flags: bool = True):
1587 return format_html_join(
1588 "\n", '<li><a href="{}">{}</a></li>', get_breadcrumbs(path_object, flags=flags)
1589 )
1592@register.simple_tag
1593def path_object_breadcrumbs5(path_object, flags: bool = True):
1594 return format_html_join(
1595 "\n",
1596 '<li class="breadcrumb-item"><a href="{}">{}</a></li>',
1597 get_breadcrumbs(path_object, flags=flags),
1598 )
1601@register.simple_tag
1602def get_projectlanguage(project: Project, language: Language) -> ProjectLanguage:
1603 return ProjectLanguage(project=project, language=language)
1606@register.simple_tag
1607def get_workflow_flags(translation: Translation | None, component: Component):
1608 if translation:
1609 return {
1610 "suggestion_voting": translation.suggestion_voting,
1611 "suggestion_autoaccept": translation.suggestion_autoaccept,
1612 "enable_suggestions": translation.enable_suggestions,
1613 "translation_review": translation.enable_review,
1614 }
1615 return {
1616 "suggestion_voting": component.suggestion_voting,
1617 "suggestion_autoaccept": component.suggestion_autoaccept,
1618 "enable_suggestions": component.enable_suggestions,
1619 "translation_review": component.project.translation_review,
1620 }
1623@register.simple_tag
1624def list_objects_number(
1625 value: int,
1626 search_url: str | None = None,
1627 translate_url: str | None = None,
1628 query: str = "",
1629 css: str | None = None,
1630 show_zero: bool = False,
1631):
1632 value_formatted: str | SafeString
1633 url_start: str | SafeString
1634 url_end: str | SafeString
1635 url_start = url_end = ""
1636 if value == 0 and not show_zero:
1637 value_formatted = format_html(
1638 """<span class="visually-hidden">{}</span>""", value
1639 )
1640 else:
1641 if search_url or translate_url:
1642 url_start = format_html(
1643 '<a href="{url}?{query}">',
1644 url=translate_url or search_url,
1645 query=query,
1646 )
1647 url_end = mark_safe("</a>")
1648 value_formatted = intcomma(value)
1649 return format_html(
1650 """
1651 <td class="number {css}" data-value="{value}">
1652 {url_start}
1653 {value_formatted}
1654 {url_end}
1655 </td>
1656 """,
1657 url_start=url_start,
1658 url_end=url_end,
1659 css=css if css is not None else "",
1660 value=value,
1661 value_formatted=value_formatted,
1662 )
1665@register.simple_tag
1666def list_objects_percent(
1667 percent: float,
1668 value: int,
1669 total: int,
1670 checks: int,
1671 search_url: str | None = None,
1672 translate_url: str | None = None,
1673 query: str = "",
1674 css: str | None = None,
1675):
1676 url_start: str | SafeString
1677 url_end: str | SafeString
1678 percent_formatted: str | SafeString
1679 if search_url or translate_url:
1680 url_start = format_html(
1681 '<a href="{url}?{query}">',
1682 url=translate_url or search_url,
1683 query=query,
1684 )
1685 url_end = mark_safe("</a>")
1686 else:
1687 url_start = url_end = ""
1689 if value and value == total and checks == 0:
1690 percent_formatted = format_html(
1691 """<span class="green" title="{}">{}</span>""",
1692 ngettext(
1693 "Completed translation with %(count)s string",
1694 "Completed translation with %(count)s strings",
1695 total,
1696 )
1697 % {"count": intcomma(total)},
1698 icon("check.svg"),
1699 )
1700 elif value == 0 and total == 0:
1701 percent_formatted = format_html(
1702 """<span class="green" title="{}">{}</span>""",
1703 gettext("No strings to translate"),
1704 icon("check.svg"),
1705 )
1706 else:
1707 percent_formatted = percent_format(percent)
1708 return format_html(
1709 """
1710 <td class="number {css}" data-value="{percent}" title="{value_formatted}">
1711 {url_start}
1712 {percent_formatted}
1713 {url_end}
1714 </td>
1715 """,
1716 url_start=url_start,
1717 url_end=url_end,
1718 css=css,
1719 percent=f"{percent:f}",
1720 percent_formatted=percent_formatted,
1721 value_formatted=gettext("%(value)s of %(all)s")
1722 % {"value": intcomma(value), "all": intcomma(total)},
1723 )
1726@register.inclusion_tag("snippets/info5.html", takes_context=True)
1727def show_info5( # noqa: PLR0913
1728 context: Context,
1729 *,
1730 project: Project | None = None,
1731 component: Component | None = None,
1732 translation: Translation | None = None,
1733 language: Language | None = None,
1734 componentlist: ComponentList | None = None,
1735 stats: BaseStats | None = None,
1736 metrics: MetricsWrapper | None = None,
1737 show_source: bool = False,
1738 show_global: bool = False,
1739 show_full_language: bool = True,
1740 top_users: QuerySet[Profile] | None = None,
1741 total_translations: int | None = None,
1742):
1743 """
1744 Render project information table.
1746 This merely exists to be able to pass default values to {% include %}.
1747 """
1748 return {
1749 "user": context["user"],
1750 "project": project,
1751 "component": component,
1752 "translation": translation,
1753 "language": language,
1754 "componentlist": componentlist,
1755 "stats": stats,
1756 "metrics": metrics,
1757 "show_source": show_source,
1758 "show_global": show_global,
1759 "show_full_language": show_full_language,
1760 "top_users": top_users,
1761 "total_translations": total_translations,
1762 "bootstrap_5": True,
1763 }
1766@register.inclusion_tag("snippets/info.html", takes_context=True)
1767def show_info( # noqa: PLR0913
1768 context: Context,
1769 *,
1770 project: Project | None = None,
1771 component: Component | None = None,
1772 translation: Translation | None = None,
1773 language: Language | None = None,
1774 componentlist: ComponentList | None = None,
1775 stats: BaseStats | None = None,
1776 metrics: MetricsWrapper | None = None,
1777 show_source: bool = False,
1778 show_global: bool = False,
1779 show_full_language: bool = True,
1780 top_users: QuerySet[Profile] | None = None,
1781 total_translations: int | None = None,
1782):
1783 """
1784 Render project information table.
1786 This merely exists to be able to pass default values to {% include %}.
1787 """
1788 return {
1789 "user": context["user"],
1790 "project": project,
1791 "component": component,
1792 "translation": translation,
1793 "language": language,
1794 "componentlist": componentlist,
1795 "stats": stats,
1796 "metrics": metrics,
1797 "show_source": show_source,
1798 "show_global": show_global,
1799 "show_full_language": show_full_language,
1800 "top_users": top_users,
1801 "total_translations": total_translations,
1802 }
1805@register.filter(is_safe=True)
1806def format_json(value: dict) -> str:
1807 return mark_safe( # noqa: S308
1808 linebreaks(json.dumps(value, indent=4), autoescape=True)
1809 )
1812@register.filter(is_safe=True)
1813def format_headers(value: dict[str, str]) -> str:
1814 return format_html_join(mark_safe("<br>"), "<b>{}</b>: {}", value.items())
1817@register.inclusion_tag("snippets/last-changes-content.html")
1818def format_last_changes_content(
1819 last_changes: Iterable[Change],
1820 user: str | User | AnonymousUser,
1821 in_email: bool = False,
1822 debug: bool = False,
1823 search_url: str | None = None,
1824 offset: int | None = None,
1825 bootstrap_5: bool = False,
1826):
1827 """
1828 Format last changes content for display.
1830 This is a simplified version of the prepare_last_changes_context function.
1831 """
1832 from weblate.trans.change_display import get_change_history_context
1834 if isinstance(user, str): # e.g in email digest
1835 user = AnonymousUser()
1837 processed_changes = []
1838 for change in last_changes:
1839 # Permissions
1840 can_revert = change.can_revert() and user.has_perm("unit.edit", change.unit)
1841 can_block_user = (
1842 change.user
1843 and not change.user.is_anonymous
1844 and change.project
1845 and change.user != user
1846 and user.has_perm("project.permissions", change.project)
1847 )
1849 processed_changes.append(
1850 {
1851 "change": change,
1852 "permissions": {
1853 "can_revert": can_revert,
1854 "can_block_user": can_block_user,
1855 },
1856 "ip_address": change.get_ip_address() if user.is_superuser else None,
1857 "history_data": get_change_history_context(change),
1858 }
1859 )
1860 return {
1861 "changes_with_context": processed_changes,
1862 "in_email": in_email,
1863 "debug": debug,
1864 "search_url": search_url,
1865 "offset": offset,
1866 "bootstrap_5": bootstrap_5,
1867 }
1870@register.simple_tag
1871def get_git_export_example_url() -> str:
1872 url = reverse(
1873 "git-export",
1874 kwargs={
1875 "path": ["PROJECT", "COMPONENT"],
1876 "git_request": "info/refs",
1877 },
1878 )
1879 # Strip trailing info/refs part:
1880 return url[:-9]