unit_converter.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. # SPDX-License-Identifier: AGPL-3.0-or-later
  2. """A plugin for converting measured values from one unit to another unit (a
  3. unit converter).
  4. The plugin looks up the symbols (given in the query term) in a list of
  5. converters, each converter is one item in the list (compare
  6. :py:obj:`ADDITIONAL_UNITS`). If the symbols are ambiguous, the matching units
  7. of measurement are evaluated. The weighting in the evaluation results from the
  8. sorting of the :py:obj:`list of unit converters<symbol_to_si>`.
  9. """
  10. from __future__ import annotations
  11. import typing
  12. import re
  13. import babel.numbers
  14. from flask_babel import gettext, get_locale
  15. from searx.wikidata_units import symbol_to_si
  16. from searx.plugins import Plugin, PluginInfo
  17. from searx.result_types import EngineResults
  18. if typing.TYPE_CHECKING:
  19. from searx.search import SearchWithPlugins
  20. from searx.extended_types import SXNG_Request
  21. from searx.plugins import PluginCfg
  22. name = ""
  23. description = gettext("")
  24. plugin_id = ""
  25. preference_section = ""
  26. CONVERT_KEYWORDS = ["in", "to", "as"]
  27. class SXNGPlugin(Plugin):
  28. """Convert between units. The result is displayed in area for the
  29. "answers".
  30. """
  31. id = "unit_converter"
  32. def __init__(self, plg_cfg: "PluginCfg") -> None:
  33. super().__init__(plg_cfg)
  34. self.info = PluginInfo(
  35. id=self.id,
  36. name=gettext("Unit converter plugin"),
  37. description=gettext("Convert between units"),
  38. preference_section="general",
  39. )
  40. def post_search(self, request: "SXNG_Request", search: "SearchWithPlugins") -> EngineResults:
  41. results = EngineResults()
  42. # only convert between units on the first page
  43. if search.search_query.pageno > 1:
  44. return results
  45. query = search.search_query.query
  46. query_parts = query.split(" ")
  47. if len(query_parts) < 3:
  48. return results
  49. for query_part in query_parts:
  50. for keyword in CONVERT_KEYWORDS:
  51. if query_part == keyword:
  52. from_query, to_query = query.split(keyword, 1)
  53. target_val = _parse_text_and_convert(from_query.strip(), to_query.strip())
  54. if target_val:
  55. results.add(results.types.Answer(answer=target_val))
  56. return results
  57. # inspired from https://stackoverflow.com/a/42475086
  58. RE_MEASURE = r'''
  59. (?P<sign>[-+]?) # +/- or nothing for positive
  60. (\s*) # separator: white space or nothing
  61. (?P<number>[\d\.,]*) # number: 1,000.00 (en) or 1.000,00 (de)
  62. (?P<E>[eE][-+]?\d+)? # scientific notation: e(+/-)2 (*10^2)
  63. (\s*) # separator: white space or nothing
  64. (?P<unit>\S+) # unit of measure
  65. '''
  66. def _parse_text_and_convert(from_query, to_query) -> str | None:
  67. # pylint: disable=too-many-branches, too-many-locals
  68. if not (from_query and to_query):
  69. return None
  70. measured = re.match(RE_MEASURE, from_query, re.VERBOSE)
  71. if not (measured and measured.group('number'), measured.group('unit')):
  72. return None
  73. # Symbols are not unique, if there are several hits for the from-unit, then
  74. # the correct one must be determined by comparing it with the to-unit
  75. # https://github.com/searxng/searxng/pull/3378#issuecomment-2080974863
  76. # first: collecting possible units
  77. source_list, target_list = [], []
  78. for symbol, si_name, from_si, to_si, orig_symbol in symbol_to_si():
  79. if symbol == measured.group('unit'):
  80. source_list.append((si_name, to_si))
  81. if symbol == to_query:
  82. target_list.append((si_name, from_si, orig_symbol))
  83. if not (source_list and target_list):
  84. return None
  85. source_to_si = target_from_si = target_symbol = None
  86. # second: find the right unit by comparing list of from-units with list of to-units
  87. for source in source_list:
  88. for target in target_list:
  89. if source[0] == target[0]: # compare si_name
  90. source_to_si = source[1]
  91. target_from_si = target[1]
  92. target_symbol = target[2]
  93. if not (source_to_si and target_from_si):
  94. return None
  95. _locale = get_locale() or 'en_US'
  96. value = measured.group('sign') + measured.group('number') + (measured.group('E') or '')
  97. value = babel.numbers.parse_decimal(value, locale=_locale)
  98. # convert value to SI unit
  99. if isinstance(source_to_si, (float, int)):
  100. value = float(value) * source_to_si
  101. else:
  102. value = source_to_si(float(value))
  103. # convert value from SI unit to target unit
  104. if isinstance(target_from_si, (float, int)):
  105. value = float(value) * target_from_si
  106. else:
  107. value = target_from_si(float(value))
  108. if measured.group('E'):
  109. # when incoming notation is scientific, outgoing notation is scientific
  110. result = babel.numbers.format_scientific(value, locale=_locale)
  111. else:
  112. result = babel.numbers.format_decimal(value, locale=_locale, format='#,##0.##########;-#')
  113. return f'{result} {target_symbol}'