package ee.ioc.phon.android.speechutils.editor; import android.text.TextUtils; import android.util.Pair; import androidx.annotation.NonNull; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.SortedMap; import java.util.regex.Matcher; import java.util.regex.Pattern; public class Command { private static final String[] EMPTY_ARRAY = new String[0]; private final static String SEPARATOR = "<___>"; private final String mLabel; private final String mComment; private final Pattern mLocale; private final Pattern mService; private final Pattern mApp; private final Pattern mUtt; private final String mReplacement; private final String mCommand; private final String[] mArgs; private final String mArgsAsStr; private final boolean mIsRepeatable; /** * @param label short label for GUI * @param comment free-form comment * @param locale locale of the utterance * @param service regular expression to match the recognizer service class name * @param app regular expression to match the calling app package name * @param utt regular expression with capturing groups to match the utterance * @param replacement replacement string for the matched substrings, typically empty in case of commands * @param id name of the command to execute, null if missing * @param args arguments of the command */ public Command(String label, String comment, Pattern locale, Pattern service, Pattern app, Pattern utt, String replacement, String id, String[] args) { mLabel = label; mComment = comment; mLocale = locale; mService = service; mApp = app; mUtt = utt; mReplacement = replacement == null ? "" : replacement; mCommand = id; if (args == null) { mArgs = EMPTY_ARRAY; } else { int i = 0; for (; i < args.length; i++) { if (args[i] == null || args[i].isEmpty()) { break; } } mArgs = Arrays.copyOf(args, i); } mArgsAsStr = TextUtils.join(SEPARATOR, mArgs); mIsRepeatable = mCommand != null && ( mCommand.equals("moveRel") || mCommand.equals("moveRelSel") || mCommand.equals("selectReBefore") || mCommand.equals("selectReAfter") || mCommand.equals("select") || mCommand.equals("deleteChars") ); } public Command(String label, String comment, Pattern locale, Pattern service, Pattern app, Pattern utt, String replacement, String id) { this(label, comment, locale, service, app, utt, replacement, id, null); } public Command(String utt, String replacement, String id, String[] args) { this(null, null, null, null, null, Pattern.compile(utt, Constants.REWRITE_PATTERN_FLAGS), replacement, id, args); } public Command(String utt, String replacement, String id) { this(null, null, null, null, null, Pattern.compile(utt, Constants.REWRITE_PATTERN_FLAGS), replacement, id, null); } public Command(String utt, String replacement) { this(null, null, null, null, null, Pattern.compile(utt, Constants.REWRITE_PATTERN_FLAGS), replacement, null, null); } public String getLabel() { return mLabel; } public String getComment() { return mComment; } public Pattern getUtterance() { return mUtt; } @NonNull public String getReplacement() { return mReplacement; } public String getId() { return mCommand; } public String[] getArgs() { return mArgs; } /** * TODO: experimental * * @param colId Column name * @return field value from the given column, converted to String */ public String get(@NonNull String colId) { switch (colId) { case UtteranceRewriter.HEADER_LABEL: return mLabel; case UtteranceRewriter.HEADER_COMMENT: return mComment; case UtteranceRewriter.HEADER_LOCALE: return mLocale.pattern(); case UtteranceRewriter.HEADER_SERVICE: return mService.pattern(); case UtteranceRewriter.HEADER_APP: return mApp.pattern(); case UtteranceRewriter.HEADER_UTTERANCE: return unre(mUtt.pattern()); case UtteranceRewriter.HEADER_REPLACEMENT: return mReplacement; case UtteranceRewriter.HEADER_COMMAND: return mCommand; case UtteranceRewriter.HEADER_ARG1: if (mArgs.length > 0) { return mArgs[0]; } break; case UtteranceRewriter.HEADER_ARG2: if (mArgs.length > 1) { return mArgs[1]; } break; default: break; } return null; } /** * Parses the given string. * If the entire string matches the utterance pattern, then extracts the arguments as well. * Example: * str = replace A with B * mUtt = replace (.*) with (.*) * mReplacement = "" * $1<___>$2 * A<___>B * m.replaceAll(mReplacement) = "" * argsEvaluated = [A, B] * * @param str string to be matched * @return pair of replacement and array of arguments */ public Pair<String, String[]> parse(CharSequence str) { Matcher m = mUtt.matcher(str); String[] argsEvaluated = null; // If the entire region matches then we evaluate the arguments as well // TODO: rethink this: we could match a sub string and do something with the // prefix and suffix if (m.matches()) { if (mArgsAsStr.isEmpty()) { argsEvaluated = EMPTY_ARRAY; } else { try { argsEvaluated = TextUtils.split(m.replaceAll(mArgsAsStr), SEPARATOR); } catch (IndexOutOfBoundsException e) { // TODO: rethink this hack; occurs in Matcher.group, e.g. when "No group 1" argsEvaluated = new String[]{e.getLocalizedMessage()}; } } } try { return new Pair<>(m.replaceAll(mReplacement), argsEvaluated); } catch (ArrayIndexOutOfBoundsException e) { // This happens if the replacement references a group that does not exist // TODO: throw an exception return new Pair<>("[ERROR: " + e.getLocalizedMessage() + "]", argsEvaluated); } } public Map<String, String> toMap(Collection<String> header) { Map<String, String> map = new HashMap<>(); for (String colName : header) { switch (colName) { case UtteranceRewriter.HEADER_LABEL: map.put(UtteranceRewriter.HEADER_LABEL, mLabel); break; case UtteranceRewriter.HEADER_COMMENT: map.put(UtteranceRewriter.HEADER_COMMENT, mComment); break; case UtteranceRewriter.HEADER_LOCALE: map.put(UtteranceRewriter.HEADER_LOCALE, mLocale.pattern()); break; case UtteranceRewriter.HEADER_SERVICE: map.put(UtteranceRewriter.HEADER_SERVICE, mService.pattern()); break; case UtteranceRewriter.HEADER_APP: map.put(UtteranceRewriter.HEADER_APP, mApp.pattern()); break; case UtteranceRewriter.HEADER_UTTERANCE: map.put(UtteranceRewriter.HEADER_UTTERANCE, mUtt.pattern()); break; case UtteranceRewriter.HEADER_REPLACEMENT: map.put(UtteranceRewriter.HEADER_REPLACEMENT, mReplacement); break; case UtteranceRewriter.HEADER_COMMAND: map.put(UtteranceRewriter.HEADER_COMMAND, mCommand); break; case UtteranceRewriter.HEADER_ARG1: if (mArgs.length > 0) { map.put(UtteranceRewriter.HEADER_ARG1, mArgs[0]); } break; case UtteranceRewriter.HEADER_ARG2: if (mArgs.length > 1) { map.put(UtteranceRewriter.HEADER_ARG2, mArgs[1]); } break; default: break; } } return map; } // TODO: simplify to accept List<String> as input (because keys are not used) public String toTsv(SortedMap<Integer, String> header) { StringBuilder sb = new StringBuilder(); boolean isFirst = true; for (SortedMap.Entry<Integer, String> entry : header.entrySet()) { if (isFirst) { isFirst = false; } else { sb.append('\t'); } switch (entry.getValue()) { case UtteranceRewriter.HEADER_LABEL: sb.append(escape(mLabel)); break; case UtteranceRewriter.HEADER_COMMENT: sb.append(escape(mComment)); break; case UtteranceRewriter.HEADER_LOCALE: sb.append(escape(mLocale)); break; case UtteranceRewriter.HEADER_SERVICE: sb.append(escape(mService)); break; case UtteranceRewriter.HEADER_APP: sb.append(escape(mApp)); break; case UtteranceRewriter.HEADER_UTTERANCE: sb.append(escape(mUtt)); break; case UtteranceRewriter.HEADER_REPLACEMENT: sb.append(escape(mReplacement)); break; case UtteranceRewriter.HEADER_COMMAND: sb.append(escape(mCommand)); break; case UtteranceRewriter.HEADER_ARG1: if (mArgs.length > 0) { sb.append(escape(mArgs[0])); } break; case UtteranceRewriter.HEADER_ARG2: if (mArgs.length > 1) { sb.append(escape(mArgs[1])); } break; default: break; } } return sb.toString(); } /** * TODO: where and why should one use this? */ @NonNull public String toString() { if (mCommand == null) { return mUtt + "/" + mReplacement; } return mUtt + "/" + mReplacement + "/" + mCommand + "(" + mArgsAsStr + ")"; } /** * True if the given command is equal to this command. Here the equality * only considers the replacement, and (if defined) the command ID and its arguments. * This means that the activation pattern (utterance), context restrictions (command matcher), * and labels and comments are ignored when testing the equality. */ public boolean equalsCommand(@NonNull Command that) { if (getId() == null) { return getReplacement().equals(that.getReplacement()); } return getReplacement().equals(that.getReplacement()) && getId().equals(that.getId()) && Arrays.equals(getArgs(), that.getArgs()); } /** * Work in progress. * Map the Utterance-field (regex) to a string that is matched by this regex. * TODO: return an iterator over all possible matches */ public String makeUtt() { String val = unre(mUtt.pattern()); if (Pattern.matches(val, val)) { return val; } return null; } public String getLabelOrString() { String label = getLabel(); if (label == null || label.isEmpty()) { label = toString(); } return label; } /** * Some commands can be executed repeatedly, e.g. moving the cursor left or searching the * remaining document for a given string. For other commands (copy, send) this does not make sense. * The UI can implement such repeatability with a press-and-hold button, but then might be * unable to support long press, scroll, swipe. * Many commands are in principle repeatable (undo, paste, typing letter "a", ...) but soft * keyboards do not commonly implement them with press-and-hold. We currently declare a small number * of cursor, selection and deletion commands as repeatable, but in the future this should be * overridable by the user in the rewrites table. * * @return True iff command is repeatable */ public boolean isRepeatable() { return mIsRepeatable; } /** * Removes ^ and $ from the given regex * TODO: experimental */ private static String unre(String re) { if (re.startsWith("^")) re = re.substring(1); if (re.endsWith("$")) re = re.substring(0, re.length() - 1); return re; } /** * Maps newlines and tabs to literals of the form "\n" and "\t". */ private static String escape(Object str) { if (str == null) { return ""; } return str.toString().replace("\n", "\\n").replace("\t", "\\t"); } /** * Maps literals of the form "\n" and "\t" to newlines and tabs. */ public static String unescape(String str) { if (str == null) { return ""; } return str.replace("\\n", "\n").replace("\\t", "\t"); } }