/**
* Copyright (C) 2021-2022 Adrien Hopkins
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package sevenUnitsGUI;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Scanner;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import sevenUnits.ProgramInfo;
import sevenUnits.unit.BaseDimension;
import sevenUnits.unit.BaseUnit;
import sevenUnits.unit.BritishImperial;
import sevenUnits.unit.LinearUnit;
import sevenUnits.unit.LinearUnitValue;
import sevenUnits.unit.LoadingException;
import sevenUnits.unit.Metric;
import sevenUnits.unit.Unit;
import sevenUnits.unit.UnitDatabase;
import sevenUnits.unit.UnitPrefix;
import sevenUnits.unit.UnitType;
import sevenUnits.unit.UnitValue;
import sevenUnits.utils.NameSymbol;
import sevenUnits.utils.Nameable;
import sevenUnits.utils.ObjectProduct;
import sevenUnits.utils.UncertainDouble;
import sevenUnitsGUI.StandardDisplayRules.FixedDecimals;
import sevenUnitsGUI.StandardDisplayRules.FixedPrecision;
import sevenUnitsGUI.StandardDisplayRules.UncertaintyBased;
/**
* An object that handles interactions between the view and the backend code
*
* @author Adrien Hopkins
* @since 2021-12-15
*/
public final class Presenter {
/**
* The place where settings are stored. Both this path and its parent
* directory may not exist.
*/
private static final Path CONFIG_FILE = userConfigDir().resolve("SevenUnits")
.resolve("config.txt");
/** The default place where units are stored. */
private static final String DEFAULT_UNITS_FILEPATH = "/unitsfile.txt";
/** The default place where dimensions are stored. */
private static final String DEFAULT_DIMENSIONS_FILEPATH = "/dimensionfile.txt";
/** The default place where exceptions are stored. */
private static final String DEFAULT_EXCEPTIONS_FILEPATH = "/metric_exceptions.txt";
/** A Predicate that returns true iff the argument is a full base unit */
private static final Predicate IS_FULL_BASE = unit -> unit instanceof LinearUnit
&& ((LinearUnit) unit).isBase();
/**
* The default locale, used in two situations:
*
* - If no text is available in your locale, uses text from this locale.
*
- Users are initialized with this locale.
*
*/
static final String DEFAULT_LOCALE = "en";
private static final List LOCAL_LOCALES = List.of("en", "fr");
private static final Path USER_LOCALES_DIR = userConfigDir()
.resolve("SevenUnits").resolve("locales");
/**
* Adds default units and dimensions to a database.
*
* @param database database to add to
* @since 2019-04-14
* @since v0.2.0
*/
private static void addDefaults(final UnitDatabase database) {
database.addUnit("metre", Metric.METRE);
database.addUnit("kilogram", Metric.KILOGRAM);
database.addUnit("gram", Metric.KILOGRAM.dividedBy(1000));
database.addUnit("second", Metric.SECOND);
database.addUnit("ampere", Metric.AMPERE);
database.addUnit("kelvin", Metric.KELVIN);
database.addUnit("mole", Metric.MOLE);
database.addUnit("candela", Metric.CANDELA);
database.addUnit("bit", Metric.BIT);
database.addUnit("unit", Metric.ONE);
// nonlinear units - must be loaded manually
database.addUnit("tempCelsius", Metric.CELSIUS);
database.addUnit("tempFahrenheit", BritishImperial.FAHRENHEIT);
// load initial dimensions
database.addDimension("Length", Metric.Dimensions.LENGTH);
database.addDimension("Mass", Metric.Dimensions.MASS);
database.addDimension("Time", Metric.Dimensions.TIME);
database.addDimension("Temperature", Metric.Dimensions.TEMPERATURE);
}
private static String displayRuleToString(
Function numberDisplayRule) {
if (numberDisplayRule instanceof FixedDecimals)
return String.format("FIXED_DECIMALS %d",
((FixedDecimals) numberDisplayRule).decimalPlaces());
if (numberDisplayRule instanceof FixedPrecision)
return String.format("FIXED_PRECISION %d",
((FixedPrecision) numberDisplayRule).significantFigures());
else if (numberDisplayRule instanceof UncertaintyBased)
return "UNCERTAINTY_BASED";
else
return numberDisplayRule.toString();
}
/**
* Determines where to wrap {@code toWrap} with a max line length of
* {@code maxLineLength}. If no good spot is found, returns -1.
*
* @since 2024-08-22
*/
private static int findLineSplit(String toWrap, int maxLineLength) {
for (var i = maxLineLength - 1; i >= 0; i--) {
if (Character.isWhitespace(toWrap.charAt(i)))
return i;
}
return -1;
}
/**
* Gets the text of a resource file as a set of strings (each one is one line
* of the text).
*
* @param filename filename to get resource from
* @return contents of file
* @since 2021-03-27
*/
private static List getLinesFromResource(String filename) {
final List lines = new ArrayList<>();
try (var stream = inputStream(filename);
var scanner = new Scanner(stream)) {
while (scanner.hasNextLine()) {
lines.add(scanner.nextLine());
}
} catch (final IOException e) {
throw new AssertionError(
"Error occurred while loading file " + filename, e);
}
return lines;
}
/**
* Gets an input stream for a resource file.
*
* @param filepath file to use as resource
* @return obtained Path
* @since 2021-03-27
*/
private static InputStream inputStream(String filepath) {
return Presenter.class.getResourceAsStream(filepath);
}
/**
* Convert a linear unit value to a string, where the number is rounded to
* the nearest integer.
*
* @since 2024-08-16
*/
private static String linearUnitValueIntToString(LinearUnitValue uv) {
return Long.toString(Math.round(uv.getValueExact())) + " " + uv.getUnit();
}
private static Map.Entry parseSettingLine(String line) {
final var equalsIndex = line.indexOf('=');
if (equalsIndex == -1)
throw new IllegalStateException(
"Settings file is malformed at line: " + line);
final var param = line.substring(0, equalsIndex);
final var value = line.substring(equalsIndex + 1);
return Map.entry(param, value);
}
/** Gets a Path from a pathname in the config file. */
private static Path pathFromConfig(String pathname) {
return CONFIG_FILE.getParent().resolve(pathname);
}
// ====== SETTINGS ======
private static String searchRuleToString(
Function, Map> searchRule) {
if (PrefixSearchRule.NO_PREFIXES.equals(searchRule))
return "NO_PREFIXES";
if (PrefixSearchRule.COMMON_PREFIXES.equals(searchRule))
return "COMMON_PREFIXES";
else if (PrefixSearchRule.ALL_METRIC_PREFIXES.equals(searchRule))
return "ALL_METRIC_PREFIXES";
else
return searchRule.toString();
}
/**
* @return true iff a and b have any elements in common
* @since 2022-04-19
*/
private static boolean sharesAnyElements(Set> a, Set> b) {
for (final Object e : a) {
if (b.contains(e))
return true;
}
return false;
}
private static Path userConfigDir() {
if (System.getProperty("os.name").startsWith("Windows")) {
final var envFolder = System.getenv("LOCALAPPDATA");
if (envFolder == null || "".equals(envFolder))
return Path.of(System.getenv("USERPROFILE"), "AppData", "Local");
else
return Path.of(envFolder);
}
final var envFolder = System.getenv("XDG_CONFIG_HOME");
if (envFolder == null || "".equals(envFolder))
return Path.of(System.getenv("HOME"), ".config");
else
return Path.of(envFolder);
}
/**
* @return {@code line} with any comments removed.
* @since 2021-03-13
*/
private static String withoutComments(String line) {
final var index = line.indexOf('#');
return index == -1 ? line : line.substring(0, index);
}
/**
* Wraps a string, ensuring no line is longer than {@code maxLineLength}.
*
* @since 2024-08-22
*/
private static String wrapString(String toWrap, int maxLineLength) {
final var wrapped = new StringBuilder(toWrap.length());
var remaining = toWrap;
while (remaining.length() > maxLineLength) {
final var spot = findLineSplit(toWrap, maxLineLength);
if (spot == -1) {
wrapped.append(remaining.substring(0, maxLineLength));
wrapped.append("-\n");
remaining = remaining.substring(maxLineLength).stripLeading();
} else {
wrapped.append(remaining.substring(0, spot));
wrapped.append("\n");
remaining = remaining.substring(spot + 1).stripLeading();
}
}
wrapped.append(remaining);
return wrapped.toString();
}
/**
* The view that this presenter communicates with
*/
private final View view;
/**
* The database that this presenter communicates with (effectively the model)
*/
final UnitDatabase database;
/**
* The rule used for parsing input numbers. Any number-string inputted into
* this program will be parsed using this method. Not implemented yet.
*/
private Function numberParsingRule;
/**
* The rule used for displaying the results of unit conversions. The result
* of unit conversions will be put into this function, and the resulting
* string will be used in the output.
*/
private Function numberDisplayRule = StandardDisplayRules
.uncertaintyBased();
/**
* A predicate that determines whether or not a certain combination of
* prefixes is allowed. If it returns false, a combination of prefixes will
* not be allowed. Prefixes are put in the list from right to left.
*/
private Predicate> prefixRepetitionRule = DefaultPrefixRepetitionRule.NO_RESTRICTION;
/**
* A rule that accepts a prefixless name-unit pair and returns a map mapping
* names to prefixed versions of that unit (including the unit itself) that
* should be searchable.
*/
private Function, Map> searchRule = PrefixSearchRule.NO_PREFIXES;
/**
* The set of units that is considered neither metric nor nonmetric for the
* purposes of the metric-imperial one-way conversion. These units are
* included in both From and To, even if One Way Conversion is enabled.
*/
private final Set metricExceptions;
/** maps locale names (e.g. 'en') to key-text maps */
final Map> locales;
/** name of locale in locales to use */
String userLocale;
/**
* If this is true, views that show units as a list will have metric units
* removed from the From unit list and imperial/USC units removed from the To
* unit list.
*/
private boolean oneWayConversionEnabled = false;
/**
* If this is false, duplicate units and prefixes will be removed from the
* unit view in views that show units as a list to choose from.
*/
private boolean showDuplicates = false;
/**
* The default unit, prefix, dimension and exception data will only be loaded
* if this variable is true.
*/
private boolean useDefaultDatafiles = true;
/** Custom unit datafiles that will be loaded by {@link #reloadData} */
private final Set customUnitFiles = new HashSet<>();
/** Custom dimension datafiles that will be loaded by {@link #reloadData} */
private final Set customDimensionFiles = new HashSet<>();
/** Custom exception datafiles that will be loaded by {@link #reloadData} */
private final Set customExceptionFiles = new HashSet<>();
/**
* Creates a Presenter
*
* @param view the view that this presenter communicates with
* @since 2021-12-15
*/
public Presenter(View view) {
this.view = view;
this.database = new UnitDatabase();
this.metricExceptions = new HashSet<>();
this.locales = this.loadLocales();
this.userLocale = DEFAULT_LOCALE;
// set default settings temporarily
if (Files.exists(CONFIG_FILE)) {
this.loadSettings(CONFIG_FILE);
}
this.reloadData();
// print out unit counts
System.out.println(this.loadStatMsg());
}
/**
* Clears then reloads all unit, prefix, dimension and exception data.
*/
public void reloadData() {
this.database.clear();
this.metricExceptions.clear();
addDefaults(this.database);
if (this.useDefaultDatafiles) {
this.loadDefaultData();
}
this.customUnitFiles.forEach(
path -> this.handleLoadErrors(this.database.loadUnitsFile(path)));
this.customDimensionFiles.forEach(
path -> this.handleLoadErrors(this.database.loadDimensionFile(path)));
this.customExceptionFiles.forEach(this::loadExceptionFile);
}
/**
* Load units, prefixes and dimensions from the default files.
*/
private void loadDefaultData() {
// load units and prefixes
try (final var units = inputStream(DEFAULT_UNITS_FILEPATH)) {
this.handleLoadErrors(this.database.loadUnitsFromStream(units));
} catch (final IOException e) {
throw new AssertionError("Loading of unitsfile.txt failed.", e);
}
// load dimensions
try (final var dimensions = inputStream(
DEFAULT_DIMENSIONS_FILEPATH)) {
this.handleLoadErrors(
this.database.loadDimensionsFromStream(dimensions));
} catch (final IOException e) {
throw new AssertionError("Loading of dimensionfile.txt failed.", e);
}
// load metric exceptions
try {
try (var exceptions = inputStream(DEFAULT_EXCEPTIONS_FILEPATH);
var scanner = new Scanner(exceptions)) {
while (scanner.hasNextLine()) {
final var line = Presenter
.withoutComments(scanner.nextLine());
if (!line.isBlank()) {
this.metricExceptions.add(line);
}
}
}
} catch (final IOException e) {
throw new AssertionError("Loading of metric_exceptions.txt failed.",
e);
}
}
/**
* Applies a search rule to an entry in a name-unit map.
*
* @param e entry
* @return stream of entries, ready for flat-mapping
* @since 2022-07-06
*/
private Stream> applySearchRule(
Map.Entry e) {
final var u = e.getValue();
if (u instanceof LinearUnit) {
final var name = e.getKey();
final Map.Entry linearEntry = Map.entry(name,
(LinearUnit) u);
return this.searchRule.apply(linearEntry).entrySet().stream().map(
entry -> Map.entry(entry.getKey(), (Unit) entry.getValue()));
}
return Stream.of(e);
}
/**
* Converts from the view's input expression to its output expression.
* Displays an error message if any of the required fields are invalid.
*
* @throws UnsupportedOperationException if the view does not support
* expression-based conversion (does
* not implement
* {@link ExpressionConversionView})
* @since 2021-12-15
*/
public void convertExpressions() {
if (!(this.view instanceof ExpressionConversionView))
throw new UnsupportedOperationException(
"This function can only be called when the view is an ExpressionConversionView");
final var xcview = (ExpressionConversionView) this.view;
final var fromExpression = xcview.getFromExpression();
final var toExpression = xcview.getToExpression();
// expressions must not be empty
if (fromExpression.isEmpty()) {
this.view.showErrorMessage("Parse Error",
"Please enter a unit expression in the From: box.");
return;
}
if (toExpression.isEmpty()) {
this.view.showErrorMessage("Parse Error",
"Please enter a unit expression in the To: box.");
return;
}
final Optional uc;
if (this.database.containsUnitSetName(toExpression)) {
uc = this.convertExpressionToNamedMultiUnit(fromExpression,
toExpression);
} else if (toExpression.contains(";")) {
final var toExpressions = toExpression.split(";");
uc = this.convertExpressionToMultiUnit(fromExpression, toExpressions);
} else {
uc = this.convertExpressionToExpression(fromExpression, toExpression);
}
uc.ifPresent(xcview::showExpressionConversionOutput);
}
/**
* Converts a unit expression to another expression.
*
* If an error happened, it is shown to the view and Optional.empty() is
* returned.
*
* @since 2024-08-15
*/
private Optional convertExpressionToExpression(
String fromExpression, String toExpression) {
// evaluate expressions
final LinearUnitValue from;
final Unit to;
try {
from = this.database.evaluateUnitExpression(fromExpression);
} catch (final IllegalArgumentException | NoSuchElementException e) {
this.view.showErrorMessage("Parse Error",
"Could not recognize text in From entry: " + e.getMessage());
return Optional.empty();
}
try {
to = this.database.getUnitFromExpression(toExpression);
} catch (final IllegalArgumentException | NoSuchElementException e) {
this.view.showErrorMessage("Parse Error",
"Could not recognize text in To entry: " + e.getMessage());
return Optional.empty();
}
// convert and show output
if (!from.getUnit().canConvertTo(to)) {
this.view.showErrorMessage("Conversion Error",
"Cannot convert between \"" + fromExpression + "\" and \""
+ toExpression + "\".");
return Optional.empty();
}
final UncertainDouble uncertainValue;
// uncertainty is meaningless for non-linear units, so we will have
// to erase uncertainty information for them
if (to instanceof LinearUnit) {
final var toLinear = (LinearUnit) to;
uncertainValue = from.convertTo(toLinear).getValue();
} else {
final var value = from.asUnitValue().convertTo(to).getValue();
uncertainValue = UncertainDouble.of(value, 0);
}
final var uc = UnitConversionRecord.valueOf(
fromExpression, toExpression, "",
this.numberDisplayRule.apply(uncertainValue));
return Optional.of(uc);
}
/**
* Convert an expression to a MultiUnit. If an error happened, it is shown to
* the view and Optional.empty() is returned.
*
* @since 2024-08-15
*/
private Optional convertExpressionToMultiUnit(
String fromExpression, String[] toExpressions) {
final LinearUnitValue from;
try {
from = this.database.evaluateUnitExpression(fromExpression);
} catch (final IllegalArgumentException | NoSuchElementException e) {
this.view.showErrorMessage("Parse Error",
"Could not recognize text in From entry: " + e.getMessage());
return Optional.empty();
}
final List toUnits = new ArrayList<>(toExpressions.length);
for (final String toExpression : toExpressions) {
try {
final var toI = this.database
.getUnitFromExpression(toExpression.trim());
if (!(toI instanceof LinearUnit)) {
this.view.showErrorMessage("Unit Type Error",
"Units separated by ';' must be linear; " + toI
+ " is not.");
return Optional.empty();
}
toUnits.add((LinearUnit) toI);
} catch (final IllegalArgumentException | NoSuchElementException e) {
this.view.showErrorMessage("Parse Error",
"Could not recognize text in To entry: " + e.getMessage());
return Optional.empty();
}
}
final List toValues;
try {
toValues = from.convertToMultiple(toUnits);
} catch (final IllegalArgumentException e) {
this.view.showErrorMessage("Unit Error",
"Invalid units separated by ';': " + e.getMessage());
return Optional.empty();
}
final var toExpression = this.linearUnitValueSumToString(toValues);
return Optional.of(
UnitConversionRecord.valueOf(fromExpression, toExpression, "", ""));
}
/**
* Convert an expression to a MultiUnit with a name from the database. If an
* error happened, it is shown to the view and Optional.empty() is returned.
*
* @since 2024-08-15
*/
private Optional convertExpressionToNamedMultiUnit(
String fromExpression, String toName) {
final LinearUnitValue from;
try {
from = this.database.evaluateUnitExpression(fromExpression);
} catch (final IllegalArgumentException | NoSuchElementException e) {
this.view.showErrorMessage("Parse Error",
"Could not recognize text in From entry: " + e.getMessage());
return Optional.empty();
}
final var toUnits = this.database.getUnitSet(toName);
final List toValues;
try {
toValues = from.convertToMultiple(toUnits);
} catch (final IllegalArgumentException e) {
this.view.showErrorMessage("Unit Error",
"Invalid units separated by ';': " + e.getMessage());
return Optional.empty();
}
final var toExpression = this.linearUnitValueSumToString(toValues);
return Optional.of(
UnitConversionRecord.valueOf(fromExpression, toExpression, "", ""));
}
/**
* Converts from the view's input unit to its output unit. Displays an error
* message if any of the required fields are invalid.
*
* @throws UnsupportedOperationException if the view does not support
* unit-based conversion (does not
* implement
* {@link UnitConversionView})
* @since 2021-12-15
*/
public void convertUnits() {
if (!(this.view instanceof UnitConversionView))
throw new UnsupportedOperationException(
"This function can only be called when the view is a UnitConversionView.");
final var ucview = (UnitConversionView) this.view;
final var fromUnitOptional = ucview.getFromSelection();
final var toUnitOptional = ucview.getToSelection();
final var inputValueString = ucview.getInputValue();
// extract values from optionals
final String fromUnitString, toUnitString;
if (fromUnitOptional.isPresent()) {
fromUnitString = fromUnitOptional.orElseThrow();
} else {
this.view.showErrorMessage("Unit Selection Error",
"Please specify a From unit");
return;
}
if (toUnitOptional.isPresent()) {
toUnitString = toUnitOptional.orElseThrow();
} else {
this.view.showErrorMessage("Unit Selection Error",
"Please specify a To unit");
return;
}
// convert strings to data, checking if anything is invalid
final Unit fromUnit;
final UncertainDouble uncertainValue;
if (this.database.containsUnitName(fromUnitString)) {
fromUnit = this.database.getUnit(fromUnitString);
} else
throw this.viewError("Nonexistent From unit: %s", fromUnitString);
try {
uncertainValue = UncertainDouble.fromRoundedString(inputValueString);
} catch (final NumberFormatException e) {
this.view.showErrorMessage("Value Error",
"Invalid value " + inputValueString);
return;
}
if (this.database.containsUnitName(toUnitString)) {
final var toUnit = this.database.getUnit(toUnitString);
ucview.showUnitConversionOutput(
this.convertUnitToUnit(fromUnitString, toUnitString,
inputValueString, fromUnit, toUnit, uncertainValue));
} else if (this.database.containsUnitSetName(toUnitString)) {
final var toMulti = this.database
.getUnitSet(toUnitString);
ucview.showUnitConversionOutput(this.convertUnitToMulti(fromUnitString,
inputValueString, fromUnit, toMulti, uncertainValue));
} else
throw this.viewError("Nonexistent To unit: %s", toUnitString);
}
private UnitConversionRecord convertUnitToMulti(String fromUnitString,
String inputValueString, Unit fromUnit, List toMulti,
UncertainDouble uncertainValue) {
for (final LinearUnit toUnit : toMulti) {
if (!fromUnit.canConvertTo(toUnit))
throw this.viewError("Could not convert between %s and %s",
fromUnit, toUnit);
}
final LinearUnitValue initValue;
if (fromUnit instanceof LinearUnit) {
final var fromLinear = (LinearUnit) fromUnit;
initValue = LinearUnitValue.of(fromLinear, uncertainValue);
} else {
initValue = UnitValue.of(fromUnit, uncertainValue.value())
.convertToBase(NameSymbol.EMPTY);
}
final var converted = initValue
.convertToMultiple(toMulti);
final var toExpression = this.linearUnitValueSumToString(converted);
return UnitConversionRecord.valueOf(fromUnitString, toExpression,
inputValueString, "");
}
private UnitConversionRecord convertUnitToUnit(String fromUnitString,
String toUnitString, String inputValueString, Unit fromUnit,
Unit toUnit, UncertainDouble uncertainValue) {
if (!fromUnit.canConvertTo(toUnit))
throw this.viewError("Could not convert between %s and %s", fromUnit,
toUnit);
// convert - we will need to erase uncertainty for non-linear units, so
// we need to treat linear and non-linear units differently
final String outputValueString;
if (fromUnit instanceof LinearUnit && toUnit instanceof LinearUnit) {
final var fromLinear = (LinearUnit) fromUnit;
final var toLinear = (LinearUnit) toUnit;
final var initialValue = LinearUnitValue.of(fromLinear,
uncertainValue);
final var converted = initialValue.convertTo(toLinear);
outputValueString = this.numberDisplayRule.apply(converted.getValue());
} else {
final var initialValue = UnitValue.of(fromUnit,
uncertainValue.value());
final var converted = initialValue.convertTo(toUnit);
outputValueString = this.numberDisplayRule
.apply(UncertainDouble.of(converted.getValue(), 0));
}
return UnitConversionRecord.valueOf(fromUnitString, toUnitString,
inputValueString, outputValueString);
}
/**
* @return true iff duplicate units are shown in unit lists
* @since 2022-03-30
*/
public boolean duplicatesShown() {
return this.showDuplicates;
}
/**
* @return text in About file
* @since 2022-02-19
*/
public String getAboutText() {
final Path customFilepath = Presenter.pathFromConfig(
"about/" + this.userLocale + ".txt");
if (Files.exists(customFilepath)) {
try {
return formatAboutText(Files.lines(customFilepath));
} catch (IOException e) {
final String filename = String.format("/about/%s.txt", this.userLocale);
return formatAboutText(Presenter.getLinesFromResource(filename).stream());
}
} else if (LOCAL_LOCALES.contains(this.userLocale)) {
final String filename = String.format("/about/%s.txt", this.userLocale);
return formatAboutText(Presenter.getLinesFromResource(filename).stream());
} else {
final String filename = String.format("/about/%s.txt", DEFAULT_LOCALE);
return formatAboutText(Presenter.getLinesFromResource(filename).stream());
}
}
private String formatAboutText(Stream rawLines) {
return rawLines
.map(Presenter::withoutComments).collect(Collectors.joining("\n"))
.replaceAll("\\[VERSION\\]", ProgramInfo.VERSION.toString())
.replaceAll("\\[LOADSTATS\\]", wrapString(this.loadStatMsg(), 72));
}
/**
* @return set of all locales available to select
* @since 2025-02-21
*/
public Set getAvailableLocales() {
return this.locales.keySet();
}
/**
* Gets a name for this dimension using the database
*
* @param dimension dimension to name
* @return name of dimension
* @since 2022-04-16
*/
String getDimensionName(ObjectProduct dimension) {
// find this dimension in the database and get its name
// if it isn't there, use the dimension's toString instead
return this.database.dimensionMap().values().stream()
.filter(d -> d.equals(dimension)).findAny().map(Nameable::getName)
.orElse(dimension.toString(Nameable::getName));
}
/**
* Gets the correct text for a provided ID. If this text is available in the
* user's locale, that text is provided. Otherwise, text is taken from the
* system default locale {@link #DEFAULT_LOCALE}.
*
* @param textID ID of text to get (used in locale files)
* @return text to be displayed
*/
public String getLocalizedText(String textID) {
final var userLocale = this.locales.get(this.userLocale);
final var defaultLocale = this.locales.get(DEFAULT_LOCALE);
return userLocale.getOrDefault(textID, defaultLocale.get(textID));
}
/**
* @return the rule that is used by this presenter to convert numbers into
* strings
* @since 2022-04-10
*/
public Function getNumberDisplayRule() {
return this.numberDisplayRule;
}
/**
* @return the rule that is used by this presenter to convert strings into
* numbers
* @since 2022-04-10
*/
@SuppressWarnings("unused") // not implemented yet
private Function getNumberParsingRule() {
return this.numberParsingRule;
}
/**
* @return the rule that determines whether a set of prefixes is valid
* @since 2022-04-19
*/
public Predicate> getPrefixRepetitionRule() {
return this.prefixRepetitionRule;
}
/**
* @return the rule that determines which units are prefixed
* @since 2022-07-08
*/
public Function, Map> getSearchRule() {
return this.searchRule;
}
/**
* @return a search rule that shows all single prefixes
* @since 2022-07-08
*/
public Function, Map> getUniversalSearchRule() {
return PrefixSearchRule.getCoherentOnlyRule(
new HashSet<>(this.database.prefixMap(true).values()));
}
/**
* @return user's selected locale
* @since 2025-02-21
*/
public String getUserLocale() {
return userLocale;
}
/**
* @return the view associated with this presenter
* @since 2022-04-19
*/
public View getView() {
return this.view;
}
/**
* Accepts a list of errors. If that list is non-empty, prints an error
* message and alerts the user.
*
* @since 2024-08-22
*/
private void handleLoadErrors(List errors) {
if (!errors.isEmpty()) {
final var errorMessage = String.format(
"%d error(s) happened while loading file:\n%s\n", errors.size(),
errors.stream().map(Throwable::getMessage)
.collect(Collectors.joining("\n")));
System.err.print(errorMessage);
this.view.showErrorMessage(errors.size() + "Loading Error(s)",
errorMessage);
}
}
/**
* @return whether or not the provided unit is semi-metric (i.e. an
* exception)
* @since 2022-04-16
*/
boolean isSemiMetric(Unit u) {
// determine if u is an exception
final var primaryName = u.getPrimaryName();
final var symbol = u.getSymbol();
return primaryName.isPresent()
&& this.metricExceptions.contains(primaryName.orElseThrow())
|| symbol.isPresent()
&& this.metricExceptions.contains(symbol.orElseThrow())
|| sharesAnyElements(this.metricExceptions, u.getOtherNames());
}
/**
* Convert a list of LinearUnitValues that you would get from a unit-set
* conversion to a string. All but the last have their numbers rendered as
* integers, since they are always integers. The last one follows the usual
* number display rule.
*
* @since 2024-08-16
*/
private String linearUnitValueSumToString(List values) {
final var integerPart = values.subList(0, values.size() - 1).stream()
.map(Presenter::linearUnitValueIntToString)
.collect(Collectors.joining(" + "));
final var last = values.get(values.size() - 1);
return integerPart + " + " + this.numberDisplayRule.apply(last.getValue())
+ " " + last.getUnit();
}
private void loadExceptionFile(Path exceptionFile) {
try (var lines = Files.lines(exceptionFile)) {
lines.map(Presenter::withoutComments)
.forEach(this.metricExceptions::add);
} catch (final IOException e) {
this.view.showErrorMessage("File Load Error",
"Error loading configured metric exception file \""
+ exceptionFile + "\": " + e.getLocalizedMessage());
}
}
/**
* Loads all available locales, including custom ones, into a map.
* @return map containing locales
*/
private Map> loadLocales() {
final Map> locales = new HashMap<>();
for (final String localeName : LOCAL_LOCALES) {
final Map locale = new HashMap<>();
String filename = "/locales/" + localeName + ".txt";
getLinesFromResource(filename).forEach(line -> addLocaleLine(locale, line));
locales.put(localeName, locale);
}
if (Files.exists(USER_LOCALES_DIR)) {
try {
Files.list(USER_LOCALES_DIR).forEach(
localeFile -> {
try {
addLocaleFile(locales, localeFile);
} catch (IOException e) {
e.printStackTrace();
}
});
} catch (IOException e) {
e.printStackTrace();
}
}
return locales;
}
private void addLocaleFile(Map> locales, Path file) throws IOException {
final Map locale = new HashMap<>();
String fileName = file.getName(file.getNameCount()-1).toString();
String localeName = fileName.substring(0, fileName.length()-4);
Files.lines(file).forEach(line -> addLocaleLine(locale, line));
locales.put(localeName, locale);
}
private void addLocaleLine(Map locale, String line) {
String[] parts = line.split("=", 2);
if (parts.length < 2) {
return;
}
locale.put(parts[0], parts[1]);
}
/**
* Loads settings from the user's settings file and applies them to the
* presenter.
*
* @param settingsFile file settings should be loaded from
* @since 2021-12-15
*/
void loadSettings(Path settingsFile) {
this.customDimensionFiles.clear();
this.customExceptionFiles.clear();
this.customUnitFiles.clear();
for (final Map.Entry setting : this
.settingsFromFile(settingsFile)) {
final var value = setting.getValue();
switch (setting.getKey()) {
// set manually to avoid the unnecessary saving of the non-manual
// methods
case "custom_dimension_file":
this.customDimensionFiles.add(pathFromConfig(value));
break;
case "custom_exception_file":
this.customExceptionFiles.add(pathFromConfig(value));
break;
case "custom_unit_file":
this.customUnitFiles.add(pathFromConfig(value));
break;
case "number_display_rule":
this.setDisplayRuleFromString(value);
break;
case "prefix_rule":
this.prefixRepetitionRule = DefaultPrefixRepetitionRule
.valueOf(value);
this.database.setPrefixRepetitionRule(this.prefixRepetitionRule);
break;
case "one_way":
this.oneWayConversionEnabled = Boolean.parseBoolean(value);
break;
case "include_duplicates":
this.showDuplicates = Boolean.parseBoolean(value);
break;
case "search_prefix_rule":
this.setSearchRuleFromString(value);
break;
case "locale":
this.userLocale = value;
break;
default:
System.err.printf("Warning: unrecognized setting \"%s\".%n",
setting.getKey());
break;
}
}
if (this.view.getPresenter() != null) {
this.updateView();
}
}
/**
* @return a message showing how much stuff has been loaded
* @since 2024-08-22
*/
private String loadStatMsg() {
return this.getLocalizedText("load_stat_msg")
.replace("[u]", Integer.toString(
this.database.unitMapPrefixless(false).size()))
.replace("[un]", Integer.toString(
this.database.unitMapPrefixless(true).size()))
.replace("[b]", Long.toString(this.database.unitMapPrefixless(false)
.values().stream().filter(IS_FULL_BASE).count()))
.replace("[p]", Integer.toString(this.database.prefixMap(false).size()))
.replace("[pn]", Integer.toString(this.database.prefixMap(true).size()))
.replace("[s]", Integer.toString(this.database.unitSetMap().size()))
.replace("[d]", Integer.toString(this.database.dimensionMap().size()));
}
/**
* @return true iff the One-Way Conversion feature is available (views that
* show units as a list will have metric units removed from the From
* unit list and imperial/USC units removed from the To unit list)
*
* @since 2022-03-30
*/
public boolean oneWayConversionEnabled() {
return this.oneWayConversionEnabled;
}
/**
* Completes creation of the presenter. This part of the initialization
* depends on the view's functions, so it cannot be run if the components
* they depend on are not created yet.
*
* @since 2022-02-26
*/
public void postViewInitialize() {
// unit conversion specific stuff
if (this.view instanceof UnitConversionView) {
final var ucview = (UnitConversionView) this.view;
ucview.setDimensionNames(this.database.dimensionMap().keySet());
}
this.updateView();
this.view.updateText();
}
void prefixSelected() {
final var selectedPrefixName = this.view
.getViewedPrefixName();
final Optional selectedPrefix = selectedPrefixName
.map(name -> this.database.containsPrefixName(name)
? this.database.getPrefix(name)
: null);
selectedPrefix
.ifPresent(prefix -> this.view.showPrefix(prefix.getNameSymbol(),
String.valueOf(prefix.getMultiplier())));
}
/**
* Saves the presenter's current settings to the config file, creating it if
* it doesn't exist.
*
* @return false iff the presenter could not write to the file
* @since 2022-04-19
*/
public boolean saveSettings() {
final var configDir = CONFIG_FILE.getParent();
if (!Files.exists(configDir)) {
try {
Files.createDirectories(configDir);
} catch (final IOException e) {
return false;
}
}
return this.writeSettings(CONFIG_FILE);
}
private void setDisplayRuleFromString(String ruleString) {
final var tokens = ruleString.split(" ");
switch (tokens[0]) {
case "FIXED_DECIMALS":
final var decimals = Integer.parseInt(tokens[1]);
this.numberDisplayRule = StandardDisplayRules.fixedDecimals(decimals);
break;
case "FIXED_PRECISION":
final var sigDigs = Integer.parseInt(tokens[1]);
this.numberDisplayRule = StandardDisplayRules.fixedPrecision(sigDigs);
break;
case "UNCERTAINTY_BASED":
this.numberDisplayRule = StandardDisplayRules.uncertaintyBased();
break;
default:
this.numberDisplayRule = StandardDisplayRules
.getStandardRule(ruleString);
break;
}
}
/**
* @param numberDisplayRule the new rule that will be used by this presenter
* to convert numbers into strings
* @since 2022-04-10
*/
public void setNumberDisplayRule(
Function numberDisplayRule) {
this.numberDisplayRule = numberDisplayRule;
}
/**
* @param numberParsingRule the new rule that will be used by this presenter
* to convert strings into numbers
* @since 2022-04-10
*/
@SuppressWarnings("unused") // not implemented yet
private void setNumberParsingRule(
Function numberParsingRule) {
this.numberParsingRule = numberParsingRule;
}
/**
* @param oneWayConversionEnabled whether not one-way conversion should be
* enabled
* @since 2022-03-30
* @see {@link #isOneWayConversionEnabled}
*/
public void setOneWayConversionEnabled(boolean oneWayConversionEnabled) {
this.oneWayConversionEnabled = oneWayConversionEnabled;
this.updateView();
}
/**
* @param prefixRepetitionRule the rule that determines whether a set of
* prefixes is valid
* @since 2022-04-19
*/
public void setPrefixRepetitionRule(
Predicate> prefixRepetitionRule) {
this.prefixRepetitionRule = prefixRepetitionRule;
this.database.setPrefixRepetitionRule(prefixRepetitionRule);
}
/**
* @param searchRule A rule that accepts a prefixless name-unit pair and
* returns a map mapping names to prefixed versions of that
* unit (including the unit itself) that should be
* searchable.
* @since 2022-07-08
*/
public void setSearchRule(
Function, Map> searchRule) {
this.searchRule = searchRule;
}
private void setSearchRuleFromString(String ruleString) {
switch (ruleString) {
case "NO_PREFIXES":
this.searchRule = PrefixSearchRule.NO_PREFIXES;
break;
case "COMMON_PREFIXES":
this.searchRule = PrefixSearchRule.COMMON_PREFIXES;
break;
case "ALL_METRIC_PREFIXES":
this.searchRule = PrefixSearchRule.ALL_METRIC_PREFIXES;
break;
default:
System.err.printf(
"Warning: unrecognized value for search_prefix_rule: %s\n",
ruleString);
}
}
/**
* @param showDuplicateUnits whether or not duplicate units should be shown
* @since 2022-03-30
*/
public void setShowDuplicates(boolean showDuplicateUnits) {
this.showDuplicates = showDuplicateUnits;
this.updateView();
}
private List> settingsFromFile(Path settingsFile) {
try (var lines = Files.lines(settingsFile)) {
return lines.map(Presenter::withoutComments)
.filter(line -> !line.isBlank()).map(Presenter::parseSettingLine)
.toList();
} catch (final IOException e) {
this.view.showErrorMessage("Settings Loading Error",
"Error loading settings file. Using default settings.");
return null;
}
}
/**
* Sets whether or not the default datafiles will be loaded.
* This method automatically updates the view's units.
*/
public void setUseDefaultDatafiles(boolean useDefaultDatafiles) {
this.useDefaultDatafiles = useDefaultDatafiles;
this.reloadData();
this.updateView();
}
/**
* Sets the user's locale, updating the view.
*
* @param userLocale locale to use
*/
public void setUserLocale(String userLocale) {
this.userLocale = userLocale;
this.view.updateText();
}
/**
* Shows a unit in the unit viewer
*
* @param u unit to show
* @since 2022-04-16
*/
private void showUnit(Unit u) {
final var nameSymbol = u.getNameSymbol();
final var isBase = u instanceof BaseUnit
|| u instanceof LinearUnit && ((LinearUnit) u).isBase();
final var definition = isBase ? "(Base unit)" : u.toDefinitionString();
final var dimensionString = this.getDimensionName(u.getDimension());
final var unitType = UnitType.getType(u, this::isSemiMetric);
this.view.showUnit(nameSymbol, definition, dimensionString, unitType);
}
/**
* Runs whenever a unit name is selected in the unit viewer. Gets the
* description of a unit and displays it.
*
* @since 2022-04-10
*/
void unitNameSelected() {
// get selected unit, if it's there and valid
final var selectedUnitName = this.view.getViewedUnitName();
final Optional selectedUnit = selectedUnitName
.map(unitName -> this.database.containsUnitName(unitName)
? this.database.getUnit(unitName)
: null);
selectedUnit.ifPresent(this::showUnit);
}
/**
* Updates the view's From and To units, if it has some
*
* @since 2021-12-15
*/
public void updateView() {
if (this.view instanceof UnitConversionView) {
final var ucview = (UnitConversionView) this.view;
final var selectedDimensionName = ucview.getSelectedDimensionName();
// load units & prefixes into viewers
this.view.setViewableUnitNames(
this.database.unitMapPrefixless(this.showDuplicates).keySet());
this.view.setViewablePrefixNames(
this.database.prefixMap(this.showDuplicates).keySet());
// get From and To units
var fromUnits = this.database.unitMapPrefixless(this.showDuplicates)
.entrySet().stream();
var toUnits = this.database.unitMapPrefixless(this.showDuplicates)
.entrySet().stream();
var unitSets = this.database.unitSetMap().entrySet().stream();
// filter by dimension, if one is selected
if (selectedDimensionName.isPresent()) {
final var viewDimension = this.database
.getDimension(selectedDimensionName.orElseThrow());
fromUnits = fromUnits.filter(
u -> viewDimension.equals(u.getValue().getDimension()));
toUnits = toUnits.filter(
u -> viewDimension.equals(u.getValue().getDimension()));
unitSets = unitSets.filter(us -> viewDimension
.equals(us.getValue().get(0).getDimension()));
}
// filter by unit type, if desired
if (this.oneWayConversionEnabled) {
fromUnits = fromUnits.filter(u -> UnitType.getType(u.getValue(),
this::isSemiMetric) != UnitType.METRIC);
toUnits = toUnits.filter(u -> UnitType.getType(u.getValue(),
this::isSemiMetric) != UnitType.NON_METRIC);
// unit sets are never considered metric
unitSets = unitSets
.filter(us -> this.metricExceptions.contains(us.getKey()));
}
// set unit names
ucview.setFromUnitNames(fromUnits.flatMap(this::applySearchRule)
.map(Map.Entry::getKey).collect(Collectors.toSet()));
final var toUnitNames = toUnits.flatMap(this::applySearchRule)
.map(Map.Entry::getKey);
final var unitSetNames = unitSets.map(Map.Entry::getKey);
final var toNames = Stream.concat(toUnitNames, unitSetNames)
.collect(Collectors.toSet());
ucview.setToUnitNames(toNames);
}
}
/**
* @return true iff the default datafiles are being used
*/
public boolean usingDefaultDatafiles() {
return this.useDefaultDatafiles;
}
/**
* @param message message to add
* @param args string formatting arguments for message
* @return AssertionError stating that an error has happened in the view's
* code
* @since 2022-04-09
*/
private AssertionError viewError(String message, Object... args) {
return new AssertionError("View Programming Error (from " + this.view
+ "): " + String.format(message, args));
}
/**
* Saves the presenter's settings to the user settings file.
*
* @param settingsFile file settings should be saved to
* @since 2021-12-15
*/
boolean writeSettings(Path settingsFile) {
try (var writer = Files.newBufferedWriter(settingsFile)) {
writer.write(String.format("number_display_rule=%s\n",
displayRuleToString(this.numberDisplayRule)));
writer.write(
String.format("prefix_rule=%s\n", this.prefixRepetitionRule));
writer.write(
String.format("one_way=%s\n", this.oneWayConversionEnabled));
writer.write(
String.format("include_duplicates=%s\n", this.showDuplicates));
writer.write(String.format("search_prefix_rule=%s\n",
searchRuleToString(this.searchRule)));
writer.write(String.format("locale=%s\n", this.userLocale));
return true;
} catch (final IOException e) {
e.printStackTrace();
this.view.showErrorMessage("I/O Error",
"Error occurred while saving settings: "
+ e.getLocalizedMessage());
return false;
}
}
}