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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4from __future__ import annotations 

5 

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 

13 

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 

25 

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 

62 

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 

65 

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 

71 

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 ) 

81 

82register = template.Library() 

83 

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>" 

90 

91GLOSSARY_TEMPLATE = """<span class="glossary-term" title="{}">""" 

92 

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} 

110 

111FLAG_TEMPLATE = '<span title="{0}" class="{1}">{2}</span>' 

112 

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>' 

118 

119 

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 

144 

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() 

156 

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 

188 

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 

207 

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) 

215 

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] 

250 

251 offset = end 

252 elif op == self.differ.DIFF_EQUAL: 

253 offset += len(data) 

254 

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) 

263 

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) 

267 

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) 

289 

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) 

331 

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 

341 

342 for i in range(start, end): 

343 locations[i].append(term) 

344 locations[end].extend([]) 

345 

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>") 

351 

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 

357 

358 def parse_search(self) -> None: 

359 """Highlights search matches.""" 

360 tag = self.match 

361 if self.match == "search": 

362 tag = "hlmatch" 

363 

364 start_tag = format_html('<span class="{}">', tag) 

365 end_tag = "</span>" 

366 

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) 

372 

373 def parse_whitespace(self) -> None: 

374 """Highlight whitespaces.""" 

375 value = self.value 

376 

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) 

380 

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) 

387 

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>") 

398 

399 def format_generator(self) -> Generator[str]: 

400 tags = self.tags 

401 value = self.value 

402 current: list[str] 

403 replacements: dict[int, str] = {} 

404 

405 # Extract tag positions 

406 positions: set[int] = set(tags.keys()) 

407 

408 # Avoid processing trailing tags in the loop 

409 positions.discard(len(value)) 

410 

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 = "&amp;" 

419 elif char == "<": 

420 next_output = "&lt;" 

421 elif char == ">": 

422 next_output = "&gt;" 

423 elif char == '"': 

424 next_output = "&quot;" 

425 elif char == "'": 

426 next_output = "&#x27;" 

427 else: 

428 raise ValueError(char) 

429 replacements[position] = next_output 

430 

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] 

435 

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) 

447 

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) 

459 

460 # Tags 

461 yield from current 

462 

463 if pos in replacements: 

464 # HTML escaped string 

465 yield replacements[pos] 

466 previous_start = pos + 1 

467 else: 

468 previous_start = pos 

469 

470 yield value[previous_start:] 

471 

472 # Trailing tags 

473 yield from tags[len(value)] 

474 

475 def format(self): 

476 return mark_safe("".join(self.format_generator())) # noqa: S308 

477 

478 

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 ) 

503 

504 

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 ) 

532 

533 

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 ) 

557 

558 

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 ) 

573 

574 

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 

592 

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 

595 

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]) 

602 

603 # We will collect part for each plural 

604 parts = [] 

605 has_content = False 

606 

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() 

612 

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) 

617 

618 # Join paragraphs 

619 content = formatter.format() 

620 

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) 

629 

630 return { 

631 "simple": simple, 

632 "wrap": wrap, 

633 "items": parts, 

634 "language": language, 

635 "unit": unit, 

636 "has_content": has_content, 

637 } 

638 

639 

640@register.simple_tag 

641def search_name(query): 

642 """Return name for a query string.""" 

643 return FILTERS.get_search_name(query) 

644 

645 

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) 

653 

654 

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) 

662 

663 

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) 

673 

674 

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 ) 

685 

686 

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 ) 

697 

698 

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) 

704 

705 

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) 

711 

712 

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 "" 

718 

719 

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} 

731 

732 

733def naturaltime_past(value, now): 

734 """Convert past dates to natural time.""" 

735 delta = now - value 

736 

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 } 

785 

786 

787def naturaltime_future(value, now): 

788 """Convert future dates to natural time.""" 

789 delta = value - now 

790 

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 } 

839 

840 

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. 

847 

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 

857 

858 # Default to current timestamp 

859 if now is None: 

860 now = timezone.now() 

861 

862 if value < now: 

863 text = naturaltime_past(value, now) 

864 else: 

865 text = naturaltime_future(value, now) 

866 

867 # Strip microseconds 

868 if isinstance(value, datetime) and not microseconds: 

869 value = value.replace(microsecond=0) 

870 

871 return format_html( 

872 '<span title="{}">{}</span>', 

873 timezone.localtime(value).isoformat(), 

874 text, 

875 ) 

876 

877 

878def get_stats(obj): 

879 if isinstance(obj, BaseStats): 

880 return obj 

881 return obj.stats 

882 

883 

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 ) 

895 

896 

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 

904 

905 approved_percent = translation_percent(approved, total, False) 

906 good_percent = translation_percent(translated, total) 

907 

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 ) 

942 

943 return format_html( 

944 """<div class="progress-stacked progress5" title="{}">{}{}</div>""", 

945 gettext("Needs attention"), 

946 approved_tag, 

947 good_tag, 

948 ) 

949 

950 

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 ) 

961 

962 

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 ) 

973 

974 

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 ) 

985 

986 

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" 

997 

998 

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) 

1028 

1029 

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`. 

1035 

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 

1049 

1050 

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 

1057 

1058 # Do we have any locations? 

1059 if not unit.location: 

1060 return "" 

1061 

1062 # Is it just an ID? 

1063 if unit.location.isdigit(): 

1064 return gettext("string ID %s") % unit.location 

1065 

1066 profile = user.profile if user else None 

1067 

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 ) 

1081 

1082 

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"] 

1089 

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 ) 

1113 

1114 

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) 

1119 

1120 

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) 

1125 

1126 

1127def _needs_agreement(component, user: User) -> bool: 

1128 if not component.agreement: 

1129 return False 

1130 return not ContributorAgreement.objects.has_agreed(user, component) 

1131 

1132 

1133@register.simple_tag(takes_context=True) 

1134def needs_agreement(context: Context, component): 

1135 return _needs_agreement(component, context["user"]) 

1136 

1137 

1138@register.simple_tag(takes_context=True) 

1139def show_contributor_agreement(context: Context, component): 

1140 if not _needs_agreement(component, context["user"]): 

1141 return "" 

1142 

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 ) 

1151 

1152 

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()}) 

1165 

1166 

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()}) 

1174 

1175 

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() 

1183 

1184 return obj.get_absolute_url() 

1185 

1186 

1187@register.simple_tag(takes_context=True) 

1188def init_unique_row_id(context) -> str: 

1189 context["row_uuid"] = get_random_identifier() 

1190 return "" 

1191 

1192 

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) 

1197 

1198 

1199@register.simple_tag 

1200def get_filter_name(name: str) -> str: 

1201 names = dict(get_filter_choice()) 

1202 return names[name] 

1203 

1204 

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 ) 

1214 

1215 

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 ) 

1226 

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 ) 

1233 

1234 if component.locked: 

1235 yield ("state/lock.svg", gettext("This translation is locked."), None) 

1236 

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 ) 

1244 

1245 

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 ) 

1253 

1254 if project.locked: 

1255 yield ("state/lock.svg", gettext("This translation is locked."), None) 

1256 

1257 

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") 

1273 

1274 if project_language is not None: 

1275 # For source language 

1276 yield from translation_alerts(project_language) 

1277 

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) 

1280 

1281 if translation is not None: 

1282 yield from translation_alerts(translation) 

1283 

1284 if component is not None: 

1285 yield from (component_alerts(component)) 

1286 elif project is not None: 

1287 yield from (project_alerts(project)) 

1288 

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) 

1297 

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 ) 

1312 

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 ) 

1321 

1322 

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 

1337 

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 

1354 

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 ) 

1381 

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 ) 

1389 

1390 return format_html("{}{}", icons, license_badge) 

1391 

1392 

1393@register.filter(is_safe=True) 

1394def markdown(text: str) -> str: 

1395 return format_html('<div class="markdown">{}</div>', render_markdown(text)) 

1396 

1397 

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) 

1407 

1408 

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 ) 

1427 

1428 

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) 

1441 

1442 

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 ) 

1460 

1461 

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())) 

1466 

1467 

1468@register.simple_tag 

1469def sort_choices(): 

1470 return SORT_CHOICES.items() 

1471 

1472 

1473@register.simple_tag(takes_context=True) 

1474def render_alert(context: Context, alert: Alert) -> str: 

1475 return alert.render(user=context["user"]) 

1476 

1477 

1478@register.simple_tag 

1479def get_message_kind(tags) -> str: 

1480 return get_message_kind_impl(tags) 

1481 

1482 

1483@register.simple_tag 

1484def any_unit_has_context(units: Iterable[Unit]) -> bool: 

1485 return any(unit.context for unit in units) 

1486 

1487 

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 ) 

1495 

1496 

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 "" 

1506 

1507 

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 

1521 

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) 

1583 

1584 

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 ) 

1590 

1591 

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 ) 

1599 

1600 

1601@register.simple_tag 

1602def get_projectlanguage(project: Project, language: Language) -> ProjectLanguage: 

1603 return ProjectLanguage(project=project, language=language) 

1604 

1605 

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 } 

1621 

1622 

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 ) 

1663 

1664 

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 = "" 

1688 

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 ) 

1724 

1725 

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. 

1745 

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 } 

1764 

1765 

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. 

1785 

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 } 

1803 

1804 

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 ) 

1810 

1811 

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()) 

1815 

1816 

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. 

1829 

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 

1833 

1834 if isinstance(user, str): # e.g in email digest 

1835 user = AnonymousUser() 

1836 

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 ) 

1848 

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 } 

1868 

1869 

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]