605 lines
No EOL
20 KiB
Java
605 lines
No EOL
20 KiB
Java
/*
|
|
* Copyright 2023-2024 the original author or authors.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* https://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*
|
|
*/
|
|
|
|
package rife.render;
|
|
|
|
import rife.tools.Localization;
|
|
import rife.tools.StringUtils;
|
|
|
|
import java.io.IOException;
|
|
import java.io.StringReader;
|
|
import java.net.HttpURLConnection;
|
|
import java.net.MalformedURLException;
|
|
import java.net.URL;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.text.Normalizer;
|
|
import java.time.ZoneId;
|
|
import java.time.ZonedDateTime;
|
|
import java.time.format.DateTimeFormatter;
|
|
import java.util.Properties;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.logging.Level;
|
|
import java.util.logging.Logger;
|
|
|
|
/**
|
|
* Collection of utility-type methods commonly used by the renderers.
|
|
*
|
|
* @author <a href="https://erik.thauvin.net/">Erik C. Thauvin</a>
|
|
* @since 1.0
|
|
*/
|
|
public final class RenderUtils {
|
|
/**
|
|
* The encoding property.
|
|
*/
|
|
public static final String ENCODING_PROPERTY = "encoding";
|
|
/**
|
|
* ISO 8601 date formatter.
|
|
*
|
|
* @see <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601</a>
|
|
*/
|
|
public static final DateTimeFormatter ISO_8601_DATE_FORMATTER =
|
|
DateTimeFormatter.ofPattern("yyyy-MM-dd").withLocale(Localization.getLocale());
|
|
/**
|
|
* ISO 8601 date and time formatter.
|
|
*
|
|
* @see <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601</a>
|
|
*/
|
|
public static final DateTimeFormatter ISO_8601_FORMATTER =
|
|
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXXXX").withLocale(Localization.getLocale());
|
|
/**
|
|
* ISO 8601 time formatter.
|
|
*
|
|
* @see <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601</a>
|
|
*/
|
|
public static final DateTimeFormatter ISO_8601_TIME_FORMATTER =
|
|
DateTimeFormatter.ofPattern("HH:mm:ss").withLocale(Localization.getLocale());
|
|
/**
|
|
* ISO 8601 Year formatter.
|
|
*
|
|
* @see <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601</a>
|
|
*/
|
|
static public final DateTimeFormatter ISO_8601_YEAR_FORMATTER =
|
|
DateTimeFormatter.ofPattern("yyyy").withLocale(Localization.getLocale());
|
|
/**
|
|
* RFC 2822 date and time formatter.
|
|
*
|
|
* @see <a href="https://www.rfc-editor.org/rfc/rfc2822">RFC 2822</a>
|
|
*/
|
|
public static final DateTimeFormatter RFC_2822_FORMATTER =
|
|
DateTimeFormatter.ofPattern("EEE, d MMM yyyy HH:mm:ss zzz").withLocale(Localization.getLocale());
|
|
private static final String DEFAULT_USER_AGENT =
|
|
"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0";
|
|
private static final Logger LOGGER = Logger.getLogger(RenderUtils.class.getName());
|
|
|
|
private RenderUtils() {
|
|
// no-op
|
|
}
|
|
|
|
/**
|
|
* Abbreviates a {@code String} to the given length using a replacement marker.
|
|
*
|
|
* @param src the source {@code String}
|
|
* @param max the maximum length of the resulting {@code String}
|
|
* @param marker the {@code String} used as a replacement marker
|
|
* @return the abbreviated {@code String}
|
|
*/
|
|
public static String abbreviate(String src, int max, String marker) {
|
|
if (src == null || src.isBlank() || marker == null) {
|
|
return src;
|
|
}
|
|
|
|
var len = src.length();
|
|
|
|
if (len <= max || max < 0) {
|
|
return src;
|
|
}
|
|
|
|
return src.substring(0, max - marker.length()) + marker;
|
|
}
|
|
|
|
/**
|
|
* Returns the Swatch Internet (.beat) Time for the give date-time.
|
|
*
|
|
* @param zonedDateTime the date and time
|
|
* @return the .beat time. (eg.: {@code @248})
|
|
*/
|
|
public static String beatTime(ZonedDateTime zonedDateTime) {
|
|
var zdt = zonedDateTime.withZoneSameInstant(ZoneId.of("UTC+01:00"));
|
|
var beats = (int) ((zdt.getSecond() + (zdt.getMinute() * 60) +
|
|
(zdt.getHour() * 3600)) / 86.4);
|
|
return String.format("@%03d", beats);
|
|
}
|
|
|
|
/**
|
|
* <p>Encodes the source {@code String} to the specified encoding.</p>
|
|
*
|
|
* <p>The supported encodings are:</p>
|
|
*
|
|
* <ul>
|
|
* <li>{@code html}</li>
|
|
* <li>{@code js}</li>
|
|
* <li>{@code json}</li>
|
|
* <li>{@code unicode}</li>
|
|
* <li>{@code url}</li>
|
|
* <li>{@code xml}</li>
|
|
* </ul>
|
|
*
|
|
* @param src the source {@code String} to encode
|
|
* @param properties the properties containing the {@link #ENCODING_PROPERTY encoding property}.
|
|
* @return the encoded {@code String}
|
|
*/
|
|
public static String encode(String src, Properties properties) {
|
|
if (src == null || src.isBlank() || properties.isEmpty()) {
|
|
return src;
|
|
}
|
|
|
|
var encoding = properties.getProperty(ENCODING_PROPERTY, "");
|
|
switch (encoding) {
|
|
case "html" -> {
|
|
return StringUtils.encodeHtml(src);
|
|
}
|
|
case "js" -> {
|
|
return encodeJs(src);
|
|
}
|
|
case "json" -> {
|
|
return StringUtils.encodeJson(src);
|
|
}
|
|
case "unicode" -> {
|
|
return StringUtils.encodeUnicode(src);
|
|
}
|
|
case "url" -> {
|
|
return StringUtils.encodeUrl(src);
|
|
}
|
|
case "xml" -> {
|
|
return StringUtils.encodeXml(src);
|
|
}
|
|
default -> {
|
|
return src;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encodes a {@code String} to JavaScript/ECMAScript.
|
|
*
|
|
* @param src the source {@code String}
|
|
* @return the encoded {@code String}
|
|
*/
|
|
public static String encodeJs(String src) {
|
|
if (src == null || src.isBlank()) {
|
|
return src;
|
|
}
|
|
|
|
var len = src.length();
|
|
var sb = new StringBuilder(len);
|
|
|
|
char c;
|
|
for (var i = 0; i < len; i++) {
|
|
c = src.charAt(i);
|
|
switch (c) {
|
|
case '\'' -> sb.append("\\'");
|
|
case '"' -> sb.append("\\\"");
|
|
case '\\' -> sb.append("\\\\");
|
|
case '/' -> sb.append("\\/");
|
|
case '\b' -> sb.append("\\b");
|
|
case '\n' -> sb.append(("\\n"));
|
|
case '\t' -> sb.append("\\t");
|
|
case '\f' -> sb.append("\\f");
|
|
case '\r' -> sb.append("\\r");
|
|
default -> sb.append(c);
|
|
}
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
/**
|
|
* Fetches the content (body) of a URL.
|
|
*
|
|
* @param url the URL {@code String}
|
|
* @param defaultContent the default content to return if none fetched
|
|
* @return the url content, or empty
|
|
*/
|
|
public static String fetchUrl(String url, String defaultContent) {
|
|
try {
|
|
var fetchUrl = new URL(url);
|
|
try {
|
|
var connection = (HttpURLConnection) fetchUrl.openConnection();
|
|
connection.setRequestProperty("User-Agent", DEFAULT_USER_AGENT);
|
|
var code = connection.getResponseCode();
|
|
if (code >= 200 && code <= 399) {
|
|
try (var inputStream = connection.getInputStream()) {
|
|
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
|
}
|
|
} else {
|
|
if (LOGGER.isLoggable(Level.WARNING)) {
|
|
LOGGER.warning("A " + code + " status code was returned by " + fetchUrl.getHost());
|
|
}
|
|
}
|
|
} catch (IOException ioe) {
|
|
if (LOGGER.isLoggable(Level.WARNING)) {
|
|
LOGGER.log(Level.WARNING, "An IO error occurred while connecting to " + fetchUrl.getHost(), ioe);
|
|
}
|
|
}
|
|
} catch (MalformedURLException ignored) {
|
|
// do nothing
|
|
}
|
|
return defaultContent;
|
|
}
|
|
|
|
/**
|
|
* <p>Returns the last 4 digits a credit card number.</p>
|
|
*
|
|
* <ul>
|
|
* <li>The number must satisfy the Luhn algorithm</li>
|
|
* <li>Non-digits are stripped from the number</li>
|
|
* </ul>
|
|
*
|
|
* @param src the credit card number
|
|
* @return the last 4 digits of the credit card number or empty
|
|
*/
|
|
public static String formatCreditCard(String src) {
|
|
if (src == null || src.isBlank()) {
|
|
return src;
|
|
}
|
|
|
|
var cc = src.replaceAll("[^0-9]", "");
|
|
|
|
if (validateCreditCard(cc)) {
|
|
return cc.substring(cc.length() - 4);
|
|
} else {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts a text {@code String} to HTML decimal entities.
|
|
*
|
|
* @param src the {@code String} to convert
|
|
* @return the converted {@code String}
|
|
*/
|
|
@SuppressWarnings("PMD.AvoidReassigningLoopVariables")
|
|
public static String htmlEntities(String src) {
|
|
if (src == null || src.isEmpty()) {
|
|
return src;
|
|
}
|
|
|
|
var len = src.length();
|
|
var sb = new StringBuilder(len * 6);
|
|
|
|
// https://stackoverflow.com/a/6766497/8356718
|
|
int codePoint;
|
|
for (var i = 0; i < len; i++) {
|
|
codePoint = src.codePointAt(i);
|
|
// Skip over the second char in a surrogate pair
|
|
if (codePoint > 0xffff) {
|
|
i++;
|
|
}
|
|
sb.append(String.format("&#%s;", codePoint));
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
/**
|
|
* Masks characters in a String.
|
|
*
|
|
* @param src the source {@code String}
|
|
* @param mask the {@code String} to mask characters with
|
|
* @param unmasked the number of characters to leave unmasked
|
|
* @param fromStart to unmask characters from the start of the {@code String}
|
|
* @return the masked {@code String}
|
|
*/
|
|
public static String mask(String src, String mask, int unmasked, boolean fromStart) {
|
|
if (src == null || src.isEmpty()) {
|
|
return src;
|
|
}
|
|
|
|
var len = src.length();
|
|
var buff = new StringBuilder(len);
|
|
if (unmasked > 0 && unmasked < len) {
|
|
if (fromStart) {
|
|
buff.append(src, 0, unmasked);
|
|
}
|
|
buff.append(mask.repeat(len - unmasked));
|
|
if (!fromStart) {
|
|
buff.append(src.substring(len - unmasked));
|
|
}
|
|
} else {
|
|
buff.append(mask.repeat(len));
|
|
}
|
|
return buff.toString();
|
|
}
|
|
|
|
/**
|
|
* Normalizes a {@code String} for inclusion in a URL path.
|
|
*
|
|
* @param src the source {@code String}
|
|
* @return the normalized {@code String}
|
|
*/
|
|
public static String normalize(String src) {
|
|
if (src == null || src.isBlank()) {
|
|
return src;
|
|
}
|
|
|
|
var normalized = Normalizer.normalize(src.trim(), Normalizer.Form.NFD).toCharArray();
|
|
|
|
var sb = new StringBuilder(normalized.length);
|
|
for (var i = 0; i < normalized.length; i++) {
|
|
var c = normalized[i];
|
|
if (c <= '\u007F') { // ASCII only
|
|
if (" &()-_=[{]}\\|;:,<.>/".indexOf(c) != -1) { // common separators
|
|
if (!sb.isEmpty() && i != normalized.length - 1 && sb.charAt(sb.length() - 1) != '-') {
|
|
sb.append('-');
|
|
}
|
|
} else if (c >= '0' && c <= '9' || c >= 'a' && c <= 'z') { // letters & digits
|
|
sb.append(c);
|
|
} else if (c >= 'A' && c <= 'Z') { // uppercase letters
|
|
sb.append((char) (c + 32)); // make lowercase
|
|
}
|
|
}
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
/**
|
|
* Returns a new {@code Properties} containing the properties specified in the given {@code String}.
|
|
*
|
|
* @param src the {@code} String containing the properties
|
|
* @return the new {@code Properties}
|
|
*/
|
|
public static Properties parsePropertiesString(String src) {
|
|
var properties = new Properties();
|
|
if (src != null && !src.isBlank()) {
|
|
try {
|
|
properties.load(new StringReader(src));
|
|
} catch (IOException ignored) {
|
|
// ignore
|
|
}
|
|
}
|
|
return properties;
|
|
}
|
|
|
|
/**
|
|
* Returns the plural form of a word, if count > 1.
|
|
*
|
|
* @param count the count
|
|
* @param word the singular word
|
|
* @param plural the plural word
|
|
* @return the singular or plural {@code String}
|
|
*/
|
|
public static String plural(final long count, final String word, final String plural) {
|
|
if (count > 1) {
|
|
return plural;
|
|
} else {
|
|
return word;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates an SVG QR Code from the given {@code String} using <a href="https://goqr.me/">goQR.me</a>.
|
|
*
|
|
* @param src the data {@code String}
|
|
* @param size the QR Code size. (e.g. {@code 150x150})
|
|
* @return the QR code
|
|
*/
|
|
public static String qrCode(String src, String size) {
|
|
if (src == null || src.isBlank()) {
|
|
return src;
|
|
}
|
|
return fetchUrl(String.format("https://api.qrserver.com/v1/create-qr-code/?format=svg&size=%s&data=%s",
|
|
StringUtils.encodeUrl(size),
|
|
StringUtils.encodeUrl(src.trim())),
|
|
src);
|
|
}
|
|
|
|
/**
|
|
* Translates a {@code String} to/from ROT13.
|
|
*
|
|
* @param src the source {@code String}
|
|
* @return the translated {@code String}
|
|
*/
|
|
public static String rot13(String src) {
|
|
if (src == null || src.isBlank()) {
|
|
return src;
|
|
}
|
|
|
|
var len = src.length();
|
|
var output = new StringBuilder(len);
|
|
|
|
for (var i = 0; i < len; i++) {
|
|
var inChar = src.charAt(i);
|
|
|
|
if (inChar >= 'A' && inChar <= 'Z') {
|
|
inChar += (char) 13;
|
|
|
|
if (inChar > 'Z') {
|
|
inChar -= (char) 26;
|
|
}
|
|
}
|
|
|
|
if (inChar >= 'a' && inChar <= 'z') {
|
|
inChar += (char) 13;
|
|
|
|
if (inChar > 'z') {
|
|
inChar -= (char) 26;
|
|
}
|
|
}
|
|
|
|
output.append(inChar);
|
|
}
|
|
|
|
return output.toString();
|
|
}
|
|
|
|
/**
|
|
* <p>Shortens a URL using <a href="https://is.gd/">is.gid</a>.</p>
|
|
*
|
|
* <p>The URL {@code String} must be a valid http or https URL.</p>
|
|
*
|
|
* <p>Based on <a href="https://github.com/ethauvin/isgd-shorten">isgd-shorten</a></p>
|
|
*
|
|
* @param url the source URL
|
|
* @return the short URL
|
|
*/
|
|
public static String shortenUrl(String url) {
|
|
if (url == null || url.isBlank() || !url.matches("^[Hh][Tt][Tt][Pp][Ss]?://\\w.*")) {
|
|
return url;
|
|
}
|
|
return fetchUrl(String.format("https://is.gd/create.php?format=simple&url=%s",
|
|
StringUtils.encodeUrl(url.trim())), url);
|
|
}
|
|
|
|
/**
|
|
* Swaps the case of a String.
|
|
*
|
|
* @param src the {@code String} to swap the case of
|
|
* @return the modified {@code String} or null
|
|
*/
|
|
@SuppressWarnings("PMD.AvoidReassigningLoopVariables")
|
|
public static String swapCase(String src) {
|
|
if (src == null || src.isBlank()) {
|
|
return src;
|
|
}
|
|
|
|
int offset = 0;
|
|
var len = src.length();
|
|
var buff = new int[len];
|
|
|
|
for (var i = 0; i < len; ) {
|
|
int newCodePoint;
|
|
var curCodePoint = src.codePointAt(i);
|
|
if (Character.isUpperCase(curCodePoint) || Character.isTitleCase(curCodePoint)) {
|
|
newCodePoint = Character.toLowerCase(curCodePoint);
|
|
} else if (Character.isLowerCase(curCodePoint)) {
|
|
newCodePoint = Character.toUpperCase(curCodePoint);
|
|
} else {
|
|
newCodePoint = curCodePoint;
|
|
}
|
|
buff[offset++] = newCodePoint;
|
|
i += Character.charCount(newCodePoint);
|
|
}
|
|
return new String(buff, 0, offset);
|
|
}
|
|
|
|
/**
|
|
* <p>Returns the formatted server uptime.</p>
|
|
*
|
|
* <p>The default Properties are:</p>
|
|
*
|
|
* <pre>
|
|
* year=\ year\u29F5u0020
|
|
* years=\ years\u29F5u0020
|
|
* month=\ month\u29F5u0020
|
|
* months=\ months\u29F5u0020
|
|
* week=\ week\u29F5u0020
|
|
* weeks=\ weeks\u29F5u0020
|
|
* day=\ day\u29F5u0020
|
|
* days=\ days\u29F5u0020
|
|
* hour=\ hour\u29F5u0020
|
|
* hours=\ hours\u29F5u0020
|
|
* minute=\ minute
|
|
* minutes=\ minutes
|
|
* </pre>
|
|
*
|
|
* @param uptime the uptime in milliseconds
|
|
* @param properties the format properties
|
|
* @return the formatted uptime
|
|
*/
|
|
@SuppressWarnings("UnnecessaryUnicodeEscape")
|
|
public static String uptime(long uptime, Properties properties) {
|
|
var sb = new StringBuilder();
|
|
|
|
var days = TimeUnit.MILLISECONDS.toDays(uptime);
|
|
var years = days / 365;
|
|
days %= 365;
|
|
var months = days / 30;
|
|
days %= 30;
|
|
var weeks = days / 7;
|
|
days %= 7;
|
|
var hours = TimeUnit.MILLISECONDS.toHours(uptime) - TimeUnit.DAYS.toHours(TimeUnit.MILLISECONDS.toDays(uptime));
|
|
var minutes = TimeUnit.MILLISECONDS.toMinutes(uptime) - TimeUnit.HOURS.toMinutes(
|
|
TimeUnit.MILLISECONDS.toHours(uptime));
|
|
|
|
if (years > 0) {
|
|
sb.append(years).append(plural(years, properties.getProperty("year", " year "),
|
|
properties.getProperty("years", " years ")));
|
|
}
|
|
|
|
if (months > 0) {
|
|
sb.append(months).append(plural(months, properties.getProperty("month", " month "),
|
|
properties.getProperty("months", " months ")));
|
|
}
|
|
|
|
if (weeks > 0) {
|
|
sb.append(weeks).append(plural(weeks, properties.getProperty("week", " week "),
|
|
properties.getProperty("weeks", " weeks ")));
|
|
}
|
|
|
|
if (days > 0) {
|
|
sb.append(days).append(plural(days, properties.getProperty("day", " day "),
|
|
properties.getProperty("days", " days ")));
|
|
}
|
|
|
|
if (hours > 0) {
|
|
sb.append(hours).append(plural(hours, properties.getProperty("hour", " hour "),
|
|
properties.getProperty("hours", " hours ")));
|
|
}
|
|
|
|
sb.append(minutes).append(plural(minutes, properties.getProperty("minute", " minute"),
|
|
properties.getProperty("minutes", " minutes")));
|
|
|
|
return sb.toString();
|
|
}
|
|
|
|
/**
|
|
* Validates a credit card number using the Luhn algorithm.
|
|
*
|
|
* @param cc the credit card number
|
|
* @return {@code true} if the credit card number is valid
|
|
*/
|
|
public static boolean validateCreditCard(String cc) {
|
|
try {
|
|
var len = cc.length();
|
|
if (len >= 8 && len <= 19) {
|
|
// Luhn algorithm
|
|
var sum = 0;
|
|
boolean second = false;
|
|
int digit;
|
|
char c;
|
|
for (int i = len - 1; i >= 0; i--) {
|
|
c = cc.charAt(i);
|
|
if (c >= '0' && c <= '9') {
|
|
digit = cc.charAt(i) - '0';
|
|
if (second) {
|
|
digit = digit * 2;
|
|
}
|
|
sum += digit / 10;
|
|
sum += digit % 10;
|
|
|
|
second = !second;
|
|
}
|
|
}
|
|
if (sum % 10 == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch (NumberFormatException ignored) {
|
|
// do nothing
|
|
}
|
|
return false;
|
|
}
|
|
|
|
} |