/**************************************************************************
OmegaT - Computer Assisted Translation (CAT) tool
with fuzzy matching, translation memory, keyword search,
glossaries, and translation leveraging into updated projects.
Copyright (C) 2000-2006 Keith Godfrey and Maxym Mykhalchuk
2007 Zoltan Bartko
2011 John Moran
2012 Alex Buloichik, Jean-Christophe Helary, Didier Briel, Thomas Cordonnier, Aaron Madlon-Kay
2013 Zoltan Bartko, Aaron Madlon-Kay
2014 Aaron Madlon-Kay
2015 Yu Tang
Home page: http://www.omegat.org/
Support center: http://groups.yahoo.com/group/OmegaT/
This file is part of OmegaT.
OmegaT is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
OmegaT 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
**************************************************************************/
package org.omegat.gui.matches;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Point;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.SwingUtilities;
import javax.swing.text.AttributeSet;
import javax.swing.text.StyledDocument;
import org.omegat.core.Core;
import org.omegat.core.data.SourceTextEntry;
import org.omegat.core.data.StringData;
import org.omegat.core.data.TMXEntry;
import org.omegat.core.matching.DiffDriver.TextRun;
import org.omegat.core.matching.NearString;
import org.omegat.core.matching.NearString.SORT_KEY;
import org.omegat.core.matching.NearString.ScoresComparator;
import org.omegat.gui.common.EntryInfoThreadPane;
import org.omegat.gui.main.DockableScrollPane;
import org.omegat.gui.main.IMainWindow;
import org.omegat.gui.preferences.PreferencesWindowController;
import org.omegat.gui.preferences.view.TMMatchesPreferencesController;
import org.omegat.tokenizer.ITokenizer;
import org.omegat.util.OConsts;
import org.omegat.util.OStrings;
import org.omegat.util.Preferences;
import org.omegat.util.StringUtil;
import org.omegat.util.Token;
import org.omegat.util.gui.DragTargetOverlay;
import org.omegat.util.gui.DragTargetOverlay.FileDropInfo;
import org.omegat.util.gui.IPaneMenu;
import org.omegat.util.gui.StaticUIUtils;
import org.omegat.util.gui.Styles;
import org.omegat.util.gui.UIThreadsUtil;
/**
* This is a Match pane, that displays fuzzy matches.
*
* @author Keith Godfrey
* @author Maxym Mykhalchuk
* @author Zoltan Bartko
* @author John Moran
* @author Alex Buloichik (alex73mail@gmail.com)
* @author Jean-Christophe Helary
* @author Didier Briel
* @author Aaron Madlon-Kay
* @author Yu Tang
*/
@SuppressWarnings("serial")
public class MatchesTextArea extends EntryInfoThreadPane<List<NearString>> implements IMatcher, IPaneMenu {
private static final String EXPLANATION = OStrings.getString("GUI_MATCHWINDOW_explanation");
private static final AttributeSet ATTRIBUTES_EMPTY = Styles.createAttributeSet(null, null, null, null);
private static final AttributeSet ATTRIBUTES_CHANGED = Styles.createAttributeSet(Styles.EditorColor.COLOR_MATCHES_CHANGED.getColor(), null, null, null);
private static final AttributeSet ATTRIBUTES_UNCHANGED = Styles.createAttributeSet(Styles.EditorColor.COLOR_MATCHES_UNCHANGED.getColor(), null, null, null);
private static final AttributeSet ATTRIBUTES_SELECTED = Styles.createAttributeSet(null, null, true, null);
private static final AttributeSet ATTRIBUTES_DELETED_ACTIVE = Styles.createAttributeSet(Styles.EditorColor.COLOR_MATCHES_DEL_ACTIVE.getColor(), null, true, null, true, null);
private static final AttributeSet ATTRIBUTES_DELETED_INACTIVE = Styles.createAttributeSet(Styles.EditorColor.COLOR_MATCHES_DEL_INACTIVE.getColor(), null, null, null, true, null);
private static final AttributeSet ATTRIBUTES_INSERTED_ACTIVE = Styles.createAttributeSet(Styles.EditorColor.COLOR_MATCHES_INS_ACTIVE.getColor(), null, true, null, null, true);
private static final AttributeSet ATTRIBUTES_INSERTED_INACTIVE = Styles.createAttributeSet(Styles.EditorColor.COLOR_MATCHES_INS_INACTIVE.getColor(), null, null, null, null, true);
private final DockableScrollPane scrollPane;
private final List<NearString> matches = new ArrayList<NearString>();
private final List<Integer> delimiters = new ArrayList<Integer>();
private final List<Integer> sourcePos = new ArrayList<Integer>();
private final List<Map<Integer, List<TextRun>>> diffInfos = new ArrayList<Map<Integer, List<TextRun>>>();
private int activeMatch = -1;
/** Creates new form MatchGlossaryPane */
public MatchesTextArea(IMainWindow mw) {
super(true);
String title = OStrings.getString("GUI_MATCHWINDOW_SUBWINDOWTITLE_Fuzzy_Matches");
scrollPane = new DockableScrollPane("MATCHES", title, this, true);
mw.addDockable(scrollPane);
setEditable(false);
StaticUIUtils.makeCaretAlwaysVisible(this);
this.setText(EXPLANATION);
setMinimumSize(new Dimension(100, 50));
addMouseListener(mouseListener);
DragTargetOverlay.apply(this, new FileDropInfo(false) {
@Override
public String getImportDestination() {
return Core.getProject().getProjectProperties().getTMRoot();
}
@Override
public boolean acceptFile(File pathname) {
return pathname.getName().toLowerCase().endsWith(OConsts.TMX_EXTENSION);
}
@Override
public String getOverlayMessage() {
return OStrings.getString("DND_ADD_TM_FILE");
}
@Override
public boolean canAcceptDrop() {
return Core.getProject().isProjectLoaded();
}
@Override
public Component getComponentToOverlay() {
return scrollPane;
}
});
}
@Override
public void onEntryActivated(SourceTextEntry newEntry) {
scrollPane.stopNotifying();
super.onEntryActivated(newEntry);
}
@Override
protected void startSearchThread(SourceTextEntry newEntry) {
new FindMatchesThread(MatchesTextArea.this, Core.getProject(), newEntry).start();
}
/**
* Sets the list of fuzzy matches to show in the pane. Each element of the
* list should be an instance of {@link NearString}.
*/
@Override
protected void setFoundResult(final SourceTextEntry se, List<NearString> newMatches) {
UIThreadsUtil.mustBeSwingThread();
clear();
if (newMatches == null) {
return;
}
if (!newMatches.isEmpty() && Preferences.isPreference(Preferences.NOTIFY_FUZZY_MATCHES)) {
scrollPane.notify(true);
}
NearString.SORT_KEY key = Preferences.getPreferenceEnumDefault(Preferences.EXT_TMX_SORT_KEY, SORT_KEY.SCORE);
newMatches.sort(Comparator.comparing(ns -> ns.scores[0], new ScoresComparator(key).reversed()));
matches.addAll(newMatches);
delimiters.add(0);
StringBuilder displayBuffer = new StringBuilder();
MatchesVarExpansion template = new MatchesVarExpansion(Preferences.getPreferenceDefault(Preferences.EXT_TMX_MATCH_TEMPLATE, MatchesVarExpansion.DEFAULT_TEMPLATE));
for (int i = 0; i < newMatches.size(); i++) {
NearString match = newMatches.get(i);
MatchesVarExpansion.Result result = template.apply(match, i + 1);
displayBuffer.append(result.text);
sourcePos.add(result.sourcePos);
diffInfos.add(result.diffInfo);
if (i < (newMatches.size() - 1))
displayBuffer.append("\n\n");
delimiters.add(displayBuffer.length());
}
setText(displayBuffer.toString());
setActiveMatch(0);
checkForReplaceTranslation();
}
@Override
protected void onProjectOpen() {
clear();
}
@Override
protected void onProjectClose() {
clear();
setText(EXPLANATION);
// We clean the ATTRIBUTE_SELECTED style set by the last displayed match
StyledDocument doc = (StyledDocument) getDocument();
doc.setCharacterAttributes(0, doc.getLength(), ATTRIBUTES_EMPTY, true);
}
/**
* {@inheritDoc}
*/
@Override
public NearString getActiveMatch() {
UIThreadsUtil.mustBeSwingThread();
if (activeMatch < 0 || activeMatch >= matches.size()) {
return null;
} else {
return matches.get(activeMatch);
}
}
/**
* Attempts to substitute numbers in a match with numbers from the source
* segment. For substitution to be done, the number of numbers must be the
* same between source and matches, and the numbers must be the same between
* the source match and the target match. The order of the numbers can be
* different between the source match and the target match. Numbers will be
* substituted at the correct location.
*
* @param source
* The source segment
* @param sourceMatch
* The source of the match
* @param targetMatch
* The target of the match
* @return The target match with numbers possibly substituted
*/
@Override
public String substituteNumbers(String source, String sourceMatch, String targetMatch) {
ITokenizer sourceTok = Core.getProject().getSourceTokenizer();
ITokenizer targetTok = Core.getProject().getTargetTokenizer();
return substituteNumbers(source, sourceMatch, targetMatch, sourceTok, targetTok);
}
static String substituteNumbers(String source, String sourceMatch, String targetMatch, ITokenizer sourceTok,
ITokenizer targetTok) {
List<String> sourceMatchNumbers = Stream.of(sourceTok.tokenizeVerbatimToStrings(sourceMatch))
.filter(MatchesTextArea::isNumber).collect(Collectors.toList());
String[] targetTokens = targetTok.tokenizeVerbatimToStrings(targetMatch);
List<String> targetMatchNumbers = Stream.of(targetTokens)
.filter(MatchesTextArea::isNumber).collect(Collectors.toList());
List<String> sourceNumbers = Stream.of(sourceTok.tokenizeVerbatimToStrings(source))
.filter(MatchesTextArea::isNumber).collect(Collectors.toList());
if (sourceMatchNumbers.size() != sourceNumbers.size() || sourceMatchNumbers.size() != targetMatchNumbers.size()
|| !new HashSet<>(sourceMatchNumbers).equals(new HashSet<>(targetMatchNumbers))) {
return targetMatch;
}
Map<Integer, Integer> locationMap = mapIndices(sourceMatchNumbers, targetMatchNumbers);
// Substitute new numbers in the target match
StringBuilder result = new StringBuilder();
int i = 0;
for (String tok : targetTokens) {
if (isNumber(tok)) {
result.append(sourceNumbers.get(locationMap.get(i)));
i++;
} else {
result.append(tok);
}
}
return result.toString();
}
/**
* Determine whether the given string is a number. Integers and simple
* doubles (not localized) are recognized.
*
* @param text
* A string
* @return True if the string represents a number
*/
private static boolean isNumber(String text) {
try {
Integer.parseInt(text);
return true;
} catch (NumberFormatException nfe) {
// Eat exception silently
}
try {
Double.parseDouble(text);
return true;
} catch (NumberFormatException nfe) {
// Eat exception silently
}
return false;
}
/**
* Create a mapping of indices of equivalent items in the given list.
* Handles duplicated items correctly.
*
* @param source
* Source list
* @param target
* Target list
* @return Map of indices from source list to target list
* @throws IllegalArgumentException
* If the lists are not the same size
*/
private static Map<Integer, Integer> mapIndices(List<?> source, List<?> target) {
if (source.size() != target.size()) {
throw new IllegalArgumentException("Lists must be the same size");
}
Map<Integer, Integer> result = new HashMap<>();
for (int i = 0; i < source.size(); i++) {
for (int j = 0; j < target.size(); j++) {
Object src = source.get(i);
Object trg = target.get(j);
if ((src == trg || src.equals(trg)) && !result.values().contains(j)) {
result.put(i, j);
break;
}
}
}
return result;
}
/**
* if WORKFLOW_OPTION "Insert best fuzzy match into target field" is set
*
* RFE "Option: Insert best match (80%+) into target field"
*
* @see <a href="https://sourceforge.net/p/omegat/feature-requests/33/">RFE
* #33</a>
*/
private void checkForReplaceTranslation() {
if (matches.isEmpty()) {
return;
}
if (Preferences.isPreference(Preferences.BEST_MATCH_INSERT)) {
int percentage = Preferences.getPreferenceDefault(Preferences.BEST_MATCH_MINIMAL_SIMILARITY,
Preferences.BEST_MATCH_MINIMAL_SIMILARITY_DEFAULT);
NearString thebest = matches.get(0);
if (thebest.scores[0].score >= percentage) {
SourceTextEntry currentEntry = Core.getEditor().getCurrentEntry();
TMXEntry te = Core.getProject().getTranslationInfo(currentEntry);
if (!te.isTranslated()) {
String prefix = "";
if (!Preferences.getPreference(Preferences.BEST_MATCH_EXPLANATORY_TEXT).isEmpty()) {
prefix = Preferences.getPreferenceDefault(Preferences.BEST_MATCH_EXPLANATORY_TEXT,
OStrings.getString("WF_DEFAULT_PREFIX"));
}
String translation = thebest.translation;
if (Preferences.isPreference(Preferences.CONVERT_NUMBERS)) {
translation =
substituteNumbers(currentEntry.getSrcText(), thebest.source, thebest.translation);
}
Core.getEditor().replaceEditText(prefix + translation);
}
}
}
}
/**
* Sets the index of an active match. It basically highlights the fuzzy
* match string selected. (numbers start from 0)
*/
@Override
public void setActiveMatch(int activeMatch) {
UIThreadsUtil.mustBeSwingThread();
if (activeMatch < 0 || activeMatch >= matches.size() || this.activeMatch == activeMatch) {
return;
}
this.activeMatch = activeMatch;
StyledDocument doc = (StyledDocument) getDocument();
doc.setCharacterAttributes(0, doc.getLength(), ATTRIBUTES_EMPTY, true);
int start = delimiters.get(activeMatch);
int end = delimiters.get(activeMatch + 1);
NearString match = matches.get(activeMatch);
// List tokens = match.str.getSrcTokenList();
ITokenizer tokenizer = Core.getProject().getSourceTokenizer();
if (tokenizer == null) {
return;
}
// Apply sourceText styling
if (sourcePos.get(activeMatch) != -1) {
Token[] tokens = tokenizer.tokenizeVerbatim(match.source);
// fix for bug 1586397
byte[] attributes = match.attr;
for (int i = 0; i < tokens.length; i++) {
Token token = tokens[i];
int tokstart = start + sourcePos.get(activeMatch) + token.getOffset();
int toklength = token.getLength();
if ((attributes[i] & StringData.UNIQ) != 0) {
doc.setCharacterAttributes(tokstart, toklength, ATTRIBUTES_CHANGED, false);
} else if ((attributes[i] & StringData.PAIR) != 0) {
doc.setCharacterAttributes(tokstart, toklength, ATTRIBUTES_UNCHANGED, false);
}
}
}
// Apply diff styling to ALL diffs, with colors only for activeMatch
// Iterate through (up to) 5 fuzzy matches
for (int i = 0; i < diffInfos.size(); i++) {
Map<Integer, List<TextRun>> diffInfo = diffInfos.get(i);
// Iterate through each diff variant (${diff}, ${diffReversed}, ...)
for (Entry<Integer, List<TextRun>> e : diffInfo.entrySet()) {
int diffPos = e.getKey();
if (diffPos != -1) {
// Iterate through each style chunk (added or deleted)
for (TextRun r : e.getValue()) {
int tokstart = delimiters.get(i) + diffPos + r.start;
switch (r.type) {
case DELETE:
doc.setCharacterAttributes(
tokstart,
r.length,
i == activeMatch ? ATTRIBUTES_DELETED_ACTIVE : ATTRIBUTES_DELETED_INACTIVE,
false);
break;
case INSERT:
doc.setCharacterAttributes(
tokstart,
r.length,
i == activeMatch ? ATTRIBUTES_INSERTED_ACTIVE : ATTRIBUTES_INSERTED_INACTIVE,
false);
break;
case NOCHANGE:
// Nothing
}
}
}
}
}
doc.setCharacterAttributes(start, end - start, ATTRIBUTES_SELECTED, false);
setCaretPosition(end - 2); // two newlines
final int fstart = start;
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
setCaretPosition(fstart);
}
});
}
/** Clears up the pane. */
@Override
public void clear() {
super.clear();
activeMatch = -1;
matches.clear();
delimiters.clear();
sourcePos.clear();
diffInfos.clear();
}
protected final transient MouseListener mouseListener = new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (e.getButton() == MouseEvent.BUTTON1 && e.getClickCount() > 1) {
setActiveMatch(getClickedItem(e.getPoint()));
}
}
@Override
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger()) {
doPopup(e.getPoint());
}
}
@Override
public void mouseReleased(MouseEvent e) {
if (e.isPopupTrigger()) {
doPopup(e.getPoint());
}
}
private int getClickedItem(Point p) {
if (matches == null || matches.isEmpty()) {
return -1;
}
// find out the clicked item
int clickedItem = -1;
// where did we click?
int mousepos = MatchesTextArea.this.viewToModel(p);
for (int i = 0; i < delimiters.size() - 1; i++) {
int start = delimiters.get(i);
int end = delimiters.get(i + 1);
if (mousepos >= start && mousepos < end) {
clickedItem = i;
break;
}
}
if (clickedItem == -1) {
clickedItem = delimiters.size() - 1;
}
if (clickedItem >= matches.size()) {
return -1;
}
return clickedItem;
}
private void doPopup(Point p) {
int clickedItem = getClickedItem(p);
if (clickedItem == -1) {
return;
}
JPopupMenu popup = new JPopupMenu();
populateContextMenu(popup, clickedItem);
popup.show(MatchesTextArea.this, p.x, p.y);
}
};
private void populateContextMenu(JPopupMenu popup, final int index) {
boolean hasMatches = Core.getProject().isProjectLoaded() && index >= 0 && index < matches.size();
if (hasMatches) {
NearString m = matches.get(index);
if (m.projs.length > 1) {
JMenuItem item = popup.add(OStrings.getString("MATCHES_PROJECTS"));
item.setEnabled(false);
for (int i = 0; i < m.projs.length; i++) {
String proj = m.projs[i];
StringBuilder b = new StringBuilder();
if (proj.equals("")) {
b.append(OStrings.getString("MATCHES_THIS_PROJECT"));
} else {
b.append(proj);
}
b.append(" ");
b.append(m.scores[i].toString());
JMenuItem pItem = popup.add(b.toString());
pItem.setEnabled(false);
}
popup.addSeparator();
}
}
JMenuItem item = popup.add(OStrings.getString("MATCHES_INSERT"));
item.addActionListener(new ActionListener() {
// the action: insert this match
@Override
public void actionPerformed(ActionEvent e) {
if (StringUtil.isEmpty(getSelectedText())) {
setActiveMatch(index);
}
Core.getMainWindow().getMainMenu().invokeAction("editInsertTranslationMenuItem", 0);
}
});
item.setEnabled(hasMatches);
item = popup.add(OStrings.getString("MATCHES_REPLACE"));
item.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
if (StringUtil.isEmpty(getSelectedText())) {
setActiveMatch(index);
}
Core.getMainWindow().getMainMenu().invokeAction("editOverwriteTranslationMenuItem", 0);
}
});
item.setEnabled(hasMatches);
popup.addSeparator();
item = popup.add(OStrings.getString("MATCHES_GO_TO_SEGMENT_SOURCE"));
item.setEnabled(hasMatches);
if (hasMatches) {
final NearString ns = matches.get(index);
String proj = ns.projs[0];
if (StringUtil.isEmpty(proj)) {
item.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
Core.getEditor().gotoEntry(ns.source, ns.key);
}
});
} else {
item.setEnabled(false);
}
}
}
/**
* Make the next match active
*/
@Override
public void setNextActiveMatch() {
if (activeMatch < matches.size()-1) {
setActiveMatch(activeMatch+1);
}
}
/**
* Make the previous match active
*/
@Override
public void setPrevActiveMatch() {
if (activeMatch > 0) {
setActiveMatch(activeMatch-1);
}
}
@Override
public void populatePaneMenu(JPopupMenu menu) {
populateContextMenu(menu, activeMatch);
menu.addSeparator();
final JMenuItem notify = new JCheckBoxMenuItem(OStrings.getString("GUI_MATCHWINDOW_SETTINGS_NOTIFICATIONS"));
notify.setSelected(Preferences.isPreference(Preferences.NOTIFY_FUZZY_MATCHES));
notify.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
Preferences.setPreference(Preferences.NOTIFY_FUZZY_MATCHES, notify.isSelected());
}
});
menu.add(notify);
menu.addSeparator();
final JMenuItem prefs = new JMenuItem(OStrings.getString("MATCHES_OPEN_PREFERENCES"));
prefs.addActionListener(e -> new PreferencesWindowController()
.show(Core.getMainWindow().getApplicationFrame(), TMMatchesPreferencesController.class));
menu.add(prefs);
}
}