Commit 08bb4ec7 authored by Alexander Lohnau's avatar Alexander Lohnau 💬 Committed by Harald Sitter

Refactor converter runner

Summary:
The logic from the converter runner has been refactored and moved to seperate files.
Additionally some foreach marcos have been refactored and the numberValue of the query gets only
calculated once, instead of being calculated for each matching unit.

Test Plan: The plugin should work as before and the fractional units in https://phabricator.kde.org/D22869 are still supported.

Reviewers: broulik, ngraham, #plasma, sitter

Reviewed By: broulik, #plasma, sitter

Subscribers: sitter, plasma-devel

Tags: #plasma

Differential Revision: https://phabricator.kde.org/D27166
parent 8d5e1a0b
......@@ -34,6 +34,7 @@ find_package(Qt5 ${QT_MIN_VERSION} CONFIG REQUIRED
Quick
Qml
Widgets
Test
)
find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS
......
add_definitions(-DTRANSLATION_DOMAIN=\"plasma_runner_converterrunner\")
set(krunner_converter_SRCS
converterrunner.cpp
)
set(krunner_converter_SRCS converterrunner.cpp)
add_library(krunner_converter MODULE ${krunner_converter_SRCS})
target_link_libraries(krunner_converter KF5::UnitConversion KF5::KIOCore KF5::I18n KF5::Runner)
target_link_libraries(krunner_converter KF5::UnitConversion KF5::I18n KF5::Runner Qt5::Widgets)
install(TARGETS krunner_converter DESTINATION ${KDE_INSTALL_PLUGINDIR})
install(FILES plasma-runner-converter.desktop DESTINATION ${KDE_INSTALL_KSERVICES5DIR})
add_library(krunner_converter_test STATIC ${krunner_converter_SRCS})
target_link_libraries(krunner_converter_test
KF5::I18n
KF5::Runner
KF5::UnitConversion
Qt5::Widgets
Qt5::Test
)
if(BUILD_TESTING)
add_subdirectory(autotests)
endif()
remove_definitions(-DQT_NO_CAST_FROM_ASCII)
include(ECMAddTests)
ecm_add_test(converterrunnertest.cpp TEST_NAME converterrunnertest LINK_LIBRARIES Qt5::Test krunner_converter_test)
/*
* Copyright (C) 2020 Alexander Lohnau <alexander.lohnau@gmx.de>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) version 3, or any
* later version accepted by the membership of KDE e.V. (or its
* successor approved by the membership of KDE e.V.), which shall
* act as a proxy defined in Section 6 of version 3 of the license.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library. If not, see <http://www.gnu.org/licenses/>.
*/
#include <QTest>
#include "../converterrunner.h"
using namespace KUnitConversion;
class ConverterRunnerTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void initTestCase();
void testMostCommonUnits();
void testSpecificTargetUnit();
void testUnitsCaseInsensitive();
void testCaseSensitiveUnits();
void testCurrency();
void testLettersAndCurrency();
void testInvalidCurrency();
void testFractions();
void testInvalidFractions();
void testSymbolsInUnits();
void testNegativeValue();
private:
ConverterRunner *runner = nullptr;
};
void ConverterRunnerTest::initTestCase()
{
setlocale(LC_ALL, "C.utf8");
qputenv("LANG", "en_US");
runner = new ConverterRunner(this, QVariantList());
runner->init();
}
/**
* Test if the most common units are displayed
*/
void ConverterRunnerTest::testMostCommonUnits()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("1m"));
runner->match(context);
Converter converter;
const auto lengthCategory = converter.category(KUnitConversion::LengthCategory);
QCOMPARE(context.matches().count(), lengthCategory.mostCommonUnits().count() -1);
}
/*
* Test if specifying a target unit works
*/
void ConverterRunnerTest::testSpecificTargetUnit()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("1m > cm"));
runner->match(context);
QCOMPARE(context.matches().count(), 1);
QCOMPARE(context.matches().first().text(), QStringLiteral("100 centimeters (cm)"));
}
/**
* Test if the units are case insensitive
*/
void ConverterRunnerTest::testUnitsCaseInsensitive()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("1Liter in ML"));
runner->match(context);
QCOMPARE(context.matches().count(), 1);
}
/**
* Test if the units that are case sensitive are correctly parsed
*/
void ConverterRunnerTest::testCaseSensitiveUnits()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("1Ms as ms"));
runner->match(context);
QCOMPARE(context.matches().count(), 1);
QCOMPARE(context.matches().first().text(), QStringLiteral("1.000.000.000 milliseconds (ms)"));
Plasma::RunnerContext context2;
context2.setQuery(QStringLiteral("1.000.000.000milliseconds>Ms"));
runner->match(context2);
QCOMPARE(context2.matches().count(), 1);
QCOMPARE(context2.matches().first().text(), "1 megasecond (Ms)");
}
/**
* Test of a currency gets converted to the most common currencies
*/
void ConverterRunnerTest::testCurrency()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("1$"));
runner->match(context);
Converter converter;
const auto currencyCategory = converter.category(KUnitConversion::CurrencyCategory);
QList<Unit> currencyUnits = currencyCategory.mostCommonUnits();
const QString currencyIsoCode = QLocale().currencySymbol(QLocale::CurrencyIsoCode);
const KUnitConversion::Unit localCurrency = currencyCategory.unit(currencyIsoCode);
if (localCurrency.isValid() && !currencyUnits.contains(localCurrency)) {
currencyUnits << localCurrency;
}
QCOMPARE(context.matches().count(), currencyUnits.count() - 1);
}
/**
* Test a combination of currency symbols and letters that is not directly supported by the conversion backend
*/
void ConverterRunnerTest::testLettersAndCurrency()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("4us$>ca$"));
runner->match(context);
QCOMPARE(context.matches().count(), 1);
QVERIFY(context.matches().first().text().contains(QLatin1String("Canadian dollars (CAD)")));
}
/**
* Test a query that matches the regex but is not valid
*/
void ConverterRunnerTest::testInvalidCurrency()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("4us$>abc$"));
runner->match(context);
QCOMPARE(context.matches().count(), 0);
}
/**
* Test if the factions are correctly parsed
*/
void ConverterRunnerTest::testFractions()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("6/3m>cm"));
runner->match(context);
QCOMPARE(context.matches().count(), 1);
QCOMPARE(context.matches().first().text(), QStringLiteral("200 centimeters (cm)"));
}
/**
* Test if an invalid query with a fraction gets rejected
*/
void ConverterRunnerTest::testInvalidFractions()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("4/4>cm"));
runner->match(context);
QCOMPARE(context.matches().count(), 0);
}
/**
* Test if symbols (other than currencies) are accepted
*/
void ConverterRunnerTest::testSymbolsInUnits()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("1000 µs as year"));
runner->match(context);
QCOMPARE(context.matches().count(), 1);
}
/**
* Test if negative values are accepted
*/
void ConverterRunnerTest::testNegativeValue()
{
Plasma::RunnerContext context;
context.setQuery(QStringLiteral("-4m as cm"));
runner->match(context);
QCOMPARE(context.matches().count(), 1);
QCOMPARE(context.matches().first().text(), "-400 centimeters (cm)");
}
QTEST_MAIN(ConverterRunnerTest)
#include "converterrunnertest.moc"
/*
* Copyright (C) 2007,2008 Petri Damstén <damu@iki.fi>
* Copyright (C) 2020 Alexander Lohnau <alexander.lohnau@gmx.de>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
......@@ -15,307 +16,250 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "converterrunner.h"
#include <QGuiApplication>
#include <QClipboard>
#include <QDesktopServices>
#include <QSet>
#include <QDebug>
#include <KLocalizedString>
#include <KUnitConversion/Converter>
#include <KUnitConversion/UnitCategory>
#include <cmath>
#define CONVERSION_CHAR QLatin1Char( '>' )
K_EXPORT_PLASMA_RUNNER(converterrunner, ConverterRunner)
class StringParser
ConverterRunner::ConverterRunner(QObject *parent, const QVariantList &args)
: Plasma::AbstractRunner(parent, args)
{
public:
enum GetType
{
GetString = 1,
GetDigit = 2
};
StringParser(const QString &s) : m_index(0), m_s(s) {}
~StringParser() {}
QString get(int type)
{
QChar current;
QString result;
passWhiteSpace();
while (true) {
current = next();
if (current.isNull()) {
break;
}
if (current.isSpace()) {
break;
}
bool number = isNumber(current);
if (type == GetDigit && !number) {
break;
}
if (type == GetString && number) {
break;
}
if(current == QLatin1Char( CONVERSION_CHAR )) {
break;
}
++m_index;
result += current;
}
return result;
setObjectName(QStringLiteral("Converter"));
//can not ignore commands: we have things like m4
setIgnoredTypes(Plasma::RunnerContext::Directory | Plasma::RunnerContext::File |
Plasma::RunnerContext::NetworkLocation);
const QString description = i18n("Converts the value of :q: when :q: is made up of "
"\"value unit [>, to, as, in] unit\". You can use the "
"Unit converter applet to find all available units.");
addSyntax(Plasma::RunnerSyntax(QStringLiteral(":q:"), description));
}
void ConverterRunner::init()
{
valueRegex = QRegularExpression(QStringLiteral("^([0-9,./+-]+)"));
const QStringList conversionWords = i18nc("list of words that can used as amount of 'unit1' [in|to|as] 'unit2'",
"in;to;as").split(QLatin1Char(';'));
QString conversionRegex;
for (const auto &word: conversionWords) {
conversionRegex.append(QLatin1Char(' ') + word + QStringLiteral(" |"));
}
conversionRegex.append(QStringLiteral(" ?> ?"));
unitSeperatorRegex = QRegularExpression(conversionRegex);
valueRegex.optimize();
unitSeperatorRegex.optimize();
insertCompatibleUnits();
addAction(copyActionId, QIcon::fromTheme(QStringLiteral("edit-copy")),
QStringLiteral("Copy number"));
addAction(copyUnitActionId, QIcon::fromTheme(QStringLiteral("edit-copy")),
QStringLiteral("Copy unit and number"));
actionList = {action(copyActionId), action(copyUnitActionId)};
}
ConverterRunner::~ConverterRunner() = default;
void ConverterRunner::match(Plasma::RunnerContext &context)
{
const QString term = context.query();
if (term.size() < 2 || !context.isValid()) {
return;
}
bool isNumber(const QChar &ch)
{
if (ch.isNumber()) {
return true;
const QRegularExpressionMatch valueRegexMatch = valueRegex.match(context.query());
if (!valueRegexMatch.hasMatch()) {
return;
}
const QString inputValueString = valueRegexMatch.captured(1);
// Get the different units by splitting up the query with the regex
QStringList unitStrings = context.query().simplified().remove(valueRegex).split(unitSeperatorRegex);
if (unitStrings.isEmpty()) {
return;
}
// Check if unit is valid, otherwise check for the value in the compatibleUnits map
QString inputUnitString = unitStrings.first().simplified();
KUnitConversion::UnitCategory inputCategory = converter.categoryForUnit(inputUnitString);
if (inputCategory.id() == KUnitConversion::InvalidCategory) {
inputUnitString = compatibleUnits.value(inputUnitString.toUpper());
if (inputUnitString.isEmpty()) {
return;
}
if (QStringLiteral(".,-+/").contains(ch)) {
return true;
inputCategory = converter.categoryForUnit(inputUnitString);
if (inputCategory.id() == KUnitConversion::InvalidCategory) {
return;
}
return false;
}
QString rest()
{
return m_s.mid(m_index).simplified();
QString outputUnitString;
if (unitStrings.size() == 2) {
outputUnitString = unitStrings.at(1).simplified();
}
void pass(const QStringList &strings)
{
passWhiteSpace();
const QString temp = m_s.mid(m_index);
foreach (const QString& s, strings) {
if (temp.startsWith(s)) {
m_index += s.length();
return;
}
}
const KUnitConversion::Unit inputUnit = inputCategory.unit(inputUnitString);
const QList<KUnitConversion::Unit> outputUnits = createResultUnits(outputUnitString, inputCategory);
const auto numberDataPair = getValidatedNumberValue(inputValueString);
// Return on invalid user input
if (!numberDataPair.first) {
return;
}
private:
void passWhiteSpace()
{
while (next().isSpace()) {
++m_index;
const double numberValue = numberDataPair.second;
QList<Plasma::QueryMatch> matches;
for (const KUnitConversion::Unit &outputUnit: outputUnits) {
KUnitConversion::Value outputValue = inputCategory.convert(
KUnitConversion::Value(numberValue, inputUnit), outputUnit);
if (!outputValue.isValid() || inputUnit == outputUnit) {
continue;
}
}
QChar next()
{
if (m_index >= m_s.size()) {
return QChar::Null;
Plasma::QueryMatch match(this);
match.setType(Plasma::QueryMatch::InformationalMatch);
match.setIconName(QStringLiteral("accessories-calculator"));
if (outputUnit.categoryId() == KUnitConversion::CurrencyCategory) {
outputValue.round(2);
match.setText(QStringLiteral("%1 (%2)").arg(outputValue.toString(0, 'f', 2), outputUnit.symbol()));
} else {
match.setText(QStringLiteral("%1 (%2)").arg(outputValue.toString(), outputUnit.symbol()));
}
return m_s.at(m_index);
match.setData(outputValue.number());
match.setRelevance(1.0 - std::abs(std::log10(outputValue.number())) / 50.0);
matches.append(match);
}
int m_index;
QString m_s;
};
context.addMatches(matches);
}
ConverterRunner::ConverterRunner(QObject* parent, const QVariantList &args)
: Plasma::AbstractRunner(parent, args)
QList<QAction *> ConverterRunner::actionsForMatch(const Plasma::QueryMatch &match)
{
Q_UNUSED(args)
setObjectName(QLatin1String( "Converter" ));
Q_UNUSED(match)
m_separators << QString( CONVERSION_CHAR );
m_separators << i18nc("list of words that can used as amount of 'unit1' [in|to|as] 'unit2'",
"in;to;as").split(QLatin1Char( ';' ));
return actionList;
}
//can not ignore commands: we have things like m4
setIgnoredTypes(Plasma::RunnerContext::Directory | Plasma::RunnerContext::File |
Plasma::RunnerContext::NetworkLocation);
void ConverterRunner::run(const Plasma::RunnerContext &context, const Plasma::QueryMatch &match)
{
Q_UNUSED(context)
QString description = i18n("Converts the value of :q: when :q: is made up of "
"\"value unit [>, to, as, in] unit\". You can use the "
"Unit converter applet to find all available units.");
addSyntax(Plasma::RunnerSyntax(QLatin1String(":q:"), description));
if (match.selectedAction() == action(copyActionId)) {
QGuiApplication::clipboard()->setText(match.data().toString());
} else {
QGuiApplication::clipboard()->setText(match.text().split(QLatin1String(" (")).first());
}
}
ConverterRunner::~ConverterRunner()
QPair<bool, double> ConverterRunner::stringToDouble(const QStringRef &value)
{
bool ok;
double numberValue = locale.toDouble(value, &ok);
if (!ok) {
numberValue = value.toDouble(&ok);
}
return {ok, numberValue};
}
void ConverterRunner::match(Plasma::RunnerContext &context)
QPair<bool, double> ConverterRunner::getValidatedNumberValue(const QString &value)
{
const QString term = context.query();
if (term.size() < 2) {
return;
#if QT_VERSION < QT_VERSION_CHECK(5, 15, 0)
const auto fractionParts = value.splitRef(QLatin1Char('/'), QString::SkipEmptyParts);
#else
const auto fractionParts = value.splitRef(QLatin1Char('/'), Qt::SkipEmptyParts);
#endif
if (fractionParts.isEmpty() || fractionParts.count() > 2) {
return {false, 0};
}
StringParser cmd(term);
QString unit1;
QString value;
QString unit2;
unit1 = cmd.get(StringParser::GetString);
value = cmd.get(StringParser::GetDigit);
if (value.isEmpty()) {
return;
}
if (unit1.isEmpty()) {
unit1 = cmd.get(StringParser::GetString | StringParser::GetDigit);
if (unit1.isEmpty()) {
return;
if (fractionParts.count() == 2) {
const QPair<bool, double> doubleFirstResults = stringToDouble(fractionParts.first());
if (!doubleFirstResults.first) {
return {false, 0};
}
}
const QString s = cmd.get(StringParser::GetString);
if (!s.isEmpty() && !m_separators.contains(s)) {
unit1 += QLatin1Char( ' ' ) + s;
}
if (s.isEmpty() || !m_separators.contains(s)) {
cmd.pass(m_separators);
}
unit2 = cmd.rest();
KUnitConversion::Converter converter;
KUnitConversion::UnitCategory category = converter.categoryForUnit(unit1);
bool found = false;
if (category.id() == KUnitConversion::InvalidCategory) {
foreach (category, converter.categories()) {
foreach (const QString& s, category.allUnits()) {
if (s.compare(unit1, Qt::CaseInsensitive) == 0) {
unit1 = s;
found = true;
break;
}
}
if (found) {
break;
}
const QPair<bool, double> doubleSecondResult = stringToDouble(fractionParts.last());
if (!doubleSecondResult.first || qFuzzyIsNull(doubleSecondResult.second)) {
return {false, 0};
}
if (!found) {
return;
return {true, doubleFirstResults.second / doubleSecondResult.second};
} else if (fractionParts.count() == 1) {
const QPair<bool, double> doubleResult = stringToDouble(fractionParts.first());
if (!doubleResult.first) {
return {false, 0};
}
return {true, doubleResult.second};
} else {
return {true, 0};
}
}
QList<KUnitConversion::Unit> ConverterRunner::createResultUnits(QString &outputUnitString,
const KUnitConversion::UnitCategory &category)
{
QList<KUnitConversion::Unit> units;
if (!unit2.isEmpty()) {
KUnitConversion::Unit u = category.unit(unit2);
if (!u.isNull() && u.isValid()) {
units.append(u);
config().writeEntry(category.name(), u.symbol());
if (!outputUnitString.isEmpty()) {
KUnitConversion::Unit outputUnit = category.unit(outputUnitString);
if (!outputUnit.isNull() && outputUnit.isValid()) {
units.append(outputUnit);
} else {
const QStringList unitStrings = category.allUnits();
QList<KUnitConversion::Unit> matchingUnits;
foreach (const QString& s, unitStrings) {
if (s.startsWith(unit2, Qt::CaseInsensitive)) {
u = category.unit(s);
if (!matchingUnits.contains(u)) {
matchingUnits << u;
// Autocompletion for the target units
outputUnitString = outputUnitString.toUpper();
for (const auto &unitStringKey: compatibleUnits.keys()) {
if (unitStringKey.startsWith(outputUnitString)) {
outputUnit = category.unit(compatibleUnits.value(unitStringKey));
if (!units.contains(outputUnit)) {
units << outputUnit;
}
}
}
units = matchingUnits;
if (units.count() == 1) {
config().writeEntry(category.name(), units[0].symbol());
}
}
} else {
units = category.mostCommonUnits();
KUnitConversion::Unit u = category.unit(config().readEntry(category.name()));
if (!u.isNull() && units.indexOf(u) < 0) {
units << u;
}
// suggest converting to the user's local currency
if (category.id() == KUnitConversion::CurrencyCategory) {
const QString &currencyIsoCode = QLocale().currencySymbol(QLocale::CurrencyIsoCode);
KUnitConversion::Unit localCurrency = category.unit(currencyIsoCode);
const KUnitConversion::Unit localCurrency = category.unit(currencyIsoCode);
if (localCurrency.isValid() && !units.contains(localCurrency)) {
units << localCurrency;
}
}
}
QList<Plasma::QueryMatch> matches;
QLocale locale;
auto stringToDouble = [&locale](const QStringRef &value, bool *ok) {
double numberValue = locale.toDouble(value, ok);
if (!(*ok)) {
numberValue = value.toDouble(ok);
}
return numberValue;
};