This project is my learning place for a few things:
- MVP (model view presenter)
- XML (parsing, editing and leveraging)
- deeper swing functionality
Concerning the XML part I have already recieved a very nice review by rolfl on my previous question. Since then quite a lot of things have changed and the current state of the code is available on github
I implemented a few features, the most significant change since back then may be the free choice of translated locale. In addition to that I now support an "Unsaved Changes" dialog upon closing.
Furthermore I have removed interfaces that have only a single implementation (so basically, all), except for the OverviewView
, which I want to implement with a different UI provider than swing.
Enter the Translation Helper.
As Entry point serves your trusty Main-Class:
public class Main {
public static final String RUBBERDUCK_PATH = "RetailCoder.VBE/UI";
public static final String ARGUMENT_MISMATCH = "Arguments do not match up. Please provide one single path to read the Rubberduck resx from";
public static final String ILLEGAL_FOLDER = "Rubberduck .resx files can only be found under RetailCoder.VBE/UI. Please give a path that points to a Rubberduck UI folder";
private Main() {
}
public static void main(final String[] args) {
// parsing the first argument given into a proper path to load the resx
// from
if (args.length != 1 && args.length != 3) {
// don't even bother!
System.out.println(ARGUMENT_MISMATCH);
return;
}
Path resxFolder = Paths.get(args[0]);
// normalize path to allow checking
resxFolder = resxFolder.normalize();
if (!resxFolder.endsWith(RUBBERDUCK_PATH)) {
System.out.println(ILLEGAL_FOLDER);
return;
}
TranslationPresenter tp = new TranslationPresenter();
OverviewModel m = new OverviewModel();
OverviewView v = new SwingOverviewView();
OverviewPresenter p = new OverviewPresenter(m, v, tp);
p.initialize();
p.loadFiles(resxFolder);
// set the selected locales if they were specified on commandline
// check whether they are available before that and fall back if they aren't
if (args.length == 3) {
final String leftLocale = args[1];
final String rightLocale = args[2];
if (m.getAvailableLocales().contains(leftLocale) && m.getAvailableLocales().contains(rightLocale)) {
p.onTranslationRequest(leftLocale, Side.LEFT);
p.onTranslationRequest(rightLocale, Side.RIGHT);
}
// "fallback"
}
p.show();
}
}
Main does quite some things actually. The arguments given are parsed and run through a sanity check. Then we fire up the Presenter, Model and View and wire them together. Surely I could clean this up a little, but I didn't find it necessary yet...
Candidates, present yourself:
The presenter has a significant working field. It is the access point for the application and controls View as well as Model and is managing their interactions. User actions that cannot be handled by the View get propagated to the presenter. There a decision between 3 Options is made:
- Handle yourself
- Delegate to model
- Delegate to a separate presenter
This gets us to following class
public class OverviewPresenter {
public static final String DEFAULT_TARGET_LOCALE = "de";
public static final String DEFAULT_ROOT_LOCALE = "";
private final Map<Side, String> chosenLocale = new EnumMap<>(Side.class);
private final OverviewModel model;
private final OverviewView view;
private final TranslationPresenter translationPresenter;
private boolean initialized = false;
public OverviewPresenter(final OverviewModel m, final OverviewView v, final TranslationPresenter p) {
model = m;
view = v;
translationPresenter = p;
view.initialize();
}
public void show() {
if (!initialized) {
initialize();
}
view.show();
}
public void initialize() {
// initialization shall only happen once!
if (initialized) {
return;
}
view.register(this);
model.register(this);
translationPresenter.register(this);
initialized = true;
}
public void onTranslationRequest(final String locale, final Side side) {
chosenLocale.put(side, locale);
rebuildView();
}
public void onException(final Exception e, final String message) {
view.displayError(message, e.getMessage());
}
public void onParseCompletion() {
rebuildView();
}
private void rebuildView() {
List<Translation> left = model.getTranslations(chosenLocale.getOrDefault(Side.LEFT, DEFAULT_ROOT_LOCALE));
List<Translation> right = model.getTranslations(chosenLocale.getOrDefault(Side.RIGHT, DEFAULT_TARGET_LOCALE));
view.rebuildWith(left, right);
}
public void loadFiles(final Path resxFolder) {
model.loadFromDirectory(resxFolder);
}
public String[] getLocaleOptions() {
return model.getAvailableLocales().toArray(new String[]{});
}
public void onTranslationSubmit(final Translation t) {
translationPresenter.hide();
model.updateTranslation(t.getLocale(), t.getKey(), t.getValue());
rebuildView();
}
public void onTranslationAbort() {
translationPresenter.hide();
}
public void onTranslateRequest(final String key) {
translationPresenter.setRequestedTranslation(
model.getSingleTranslation(chosenLocale.getOrDefault(Side.LEFT, DEFAULT_ROOT_LOCALE), key),
model.getSingleTranslation(chosenLocale.getOrDefault(Side.RIGHT, DEFAULT_TARGET_LOCALE), key)
);
translationPresenter.show();
}
public void onSaveRequest() {
model.saveAll();
}
public void onWindowCloseRequest(WindowEvent windowEvent) {
if (model.isNotSaved()) {
// prompt to save changes
int choice = JOptionPane.showConfirmDialog(windowEvent.getWindow(),
"You have unsaved changes. Do you wish to save before exiting?",
"Unsaved Changes",
JOptionPane.YES_NO_CANCEL_OPTION);
switch (choice) {
case JOptionPane.YES_OPTION:
model.saveAll();
// fallthrough intended
case JOptionPane.NO_OPTION:
view.hide();
System.exit(0);
break;
case JOptionPane.CANCEL_OPTION:
// do nothing
break;
}
} else {
System.exit(0);
}
}
}
No presenting without a view:
Whoever expected goodies now, will be sorely disappointed. The Translation Helper is incredibly ugly. Well at least it resizes nicely, has two columns and looks okay enough while doing that. That's enough for me.
To make this as simple as possible for me I settled on using a GridBagLayout to enable resizing without any additional code from my side. On initialization I set some constraints, and that's it. Well not quite. After the layouting is done there's basically two things that can happen:
- The locales to display change
- The presenter changes
For both cases I need to ensure integrity of functionality, and as such these two things happen in methods called externally. That said here's the Swing code. Anyone not interested in boring manual layouting and event-bindings should skip this block:
public class SwingOverviewView implements OverviewView {
private static final Dimension MINIMUM_WINDOW_SIZE = new Dimension(800, 500);
private static final Dimension DEFAULT_WINDOW_SIZE = new Dimension(1000, 700);
private static final Dimension MENU_BAR_DIMENSION = new Dimension(800, 100);
private static final Dimension BUTTON_DIMENSION = new Dimension(100, 40);
private final JFrame window;
private final JTable translationContainer;
private final JPanel menuBar;
private final JButton saveButton;
private final JButton chooseLeft;
private final JButton chooseRight;
private OverviewPresenter presenter;
public SwingOverviewView() {
window = new JFrame("Rubberduck Translation Helper");
window.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
translationContainer = new JTable();
translationContainer.setModel(new TranslationTable());
menuBar = new JPanel();
saveButton = new JButton("save");
chooseLeft = new JButton("choose left");
chooseRight = new JButton("choose right");
}
@Override
public void register(final OverviewPresenter p) {
presenter = p;
saveButton.addActionListener(event -> presenter.onSaveRequest());
chooseLeft.addActionListener(event -> chooseAndLoadLanguage(Side.LEFT));
chooseRight.addActionListener(event -> chooseAndLoadLanguage(Side.RIGHT));
window.addWindowListener(new WindowListener() {
@Override
public void windowOpened(WindowEvent windowEvent) {
// nothing
}
@Override
public void windowClosing(WindowEvent windowEvent) {
p.onWindowCloseRequest(windowEvent);
}
@Override
public void windowClosed(WindowEvent windowEvent) {
// nothing
}
@Override
public void windowIconified(WindowEvent windowEvent) {
// nothing
}
@Override
public void windowDeiconified(WindowEvent windowEvent) {
// nothing
}
@Override
public void windowActivated(WindowEvent windowEvent) {
// nothing
}
@Override
public void windowDeactivated(WindowEvent windowEvent) {
// nothing
}
});
}
private void chooseAndLoadLanguage(Side side) {
String locale = chooseLocale();
presenter.onTranslationRequest(locale, side);
}
private String chooseLocale() {
String[] localeOptions = presenter.getLocaleOptions();
int selectedOption = JOptionPane.showOptionDialog(window,
"Please choose the Locale out of following options:",
"Choose Locale",
JOptionPane.DEFAULT_OPTION,
JOptionPane.QUESTION_MESSAGE,
null,
localeOptions,
null);
return localeOptions[selectedOption];
}
@Override
public void initialize() {
window.setLayout(new GridBagLayout());
window.setSize(DEFAULT_WINDOW_SIZE);
window.setMinimumSize(MINIMUM_WINDOW_SIZE);
window.setBackground(new Color(0.2f, 0.3f, 0.7f, 1.0f));
addMenuBar();
addTranslationContainer();
window.doLayout();
}
private void addTranslationContainer() {
GridBagConstraints constraints = new GridBagConstraints();
constraints.insets = new Insets(15, 15, 15, 15);
constraints.weightx = 1.0;
constraints.weighty = 1.0;
constraints.fill = BOTH;
constraints.gridx = 0;
constraints.gridy = 1;
JScrollPane scroller = new JScrollPane(translationContainer);
scroller.setMinimumSize(new Dimension(800, 400));
scroller.setSize(new Dimension(800, 400));
window.add(scroller, constraints);
bindEventListener();
translationContainer.setDefaultRenderer(Object.class,
new TranslationTableRenderer());
}
private void bindEventListener() {
translationContainer.addMouseListener(new MouseListener() {
@Override
public void mouseClicked(final MouseEvent event) {
if (event.getClickCount() != 2) { // only react to doubleclicks!
return;
}
final int row = translationContainer.rowAtPoint(event
.getPoint());
final String key = ((TranslationTable) translationContainer
.getModel()).getKeyAt(row);
presenter.onTranslateRequest(key);
}
@Override
public void mouseEntered(final MouseEvent arg0) {
// IGNORE
}
@Override
public void mouseExited(final MouseEvent arg0) {
// IGNORE
}
@Override
public void mousePressed(final MouseEvent arg0) {
// IGNORE
}
@Override
public void mouseReleased(final MouseEvent arg0) {
// IGNORE
}
});
}
private void addMenuBar() {
GridBagConstraints constraints = new GridBagConstraints();
constraints.insets = new Insets(15, 15, 15, 15);
constraints.gridx = 0;
constraints.gridy = 0;
constraints.weightx = 1.0;
constraints.weighty = 0.0;
constraints.fill = BOTH;
menuBar.setLayout(new GridBagLayout());
menuBar.setBackground(new Color(0.4f, 0.2f, 0.4f, 0.2f));
addToGridBag(menuBar, window, MENU_BAR_DIMENSION, constraints);
GridBagConstraints buttonConstraints = (GridBagConstraints) constraints.clone();
buttonConstraints.gridx = GridBagConstraints.RELATIVE;
addToGridBag(chooseLeft, menuBar, BUTTON_DIMENSION, buttonConstraints);
addToGridBag(chooseRight, menuBar, BUTTON_DIMENSION, buttonConstraints);
addToGridBag(saveButton, menuBar, BUTTON_DIMENSION, buttonConstraints);
}
@Override
public void rebuildWith(final List<Translation> left, final List<Translation> right) {
translationContainer.setModel(new TranslationTable(left, right));
}
@Override
public void displayError(final String title, final String errorMessage) {
JOptionPane.showMessageDialog(window, errorMessage, title,
JOptionPane.ERROR_MESSAGE);
}
@Override
public void show() {
window.setVisible(true);
}
@Override
public void hide() {
window.setVisible(false);
}
}
But what should I show?
Exactly. That's what the Model is responsible for. It does a significant bit of the actually interesting functionality, namely:
- Parsing
.resx
files at a location - Writing edited
.resx
files back to that location
For that it relies on the java.nio
-API and JDOM, as well as the new Streams. This is the interesting part that would need a whole rewrite to support arbitrary files and other interesting stuff. Luckily that is not what I want :)
public class OverviewModel {
public static final String VALUE_NAME = "value";
public static final String KEY_NAME = "name";
public static final String SINGLE_TRUTH_LOCALE = "";
private static final String ELEMENT_NAME = "data";
private static final String FILE_NAME_FORMAT = "RubberduckUI%s.resx";
private static final String FILENAME_REGEX = "^.*RubberduckUI\\.?([a-z]{2})?\\.resx$";
private static final Pattern localeFinder = Pattern.compile(FILENAME_REGEX);
private final Map<String, Document> translations = new HashMap<>();
private final XPathFactory xPathFactory = XPathFactory.instance();
private final XPathExpression<Element> valueExpression = xPathFactory.compile("/*/"
+ ELEMENT_NAME + "[@" + KEY_NAME + "=$key]/"
+ VALUE_NAME, Filters.element(), Collections.singletonMap("key", ""));
private OverviewPresenter presenter;
private Path currentPath;
private final AtomicBoolean saved = new AtomicBoolean(true);
public static final XMLOutputter XML_PRETTY_PRINT = new XMLOutputter(Format.getPrettyFormat());
private static String parseFileName(final Path path) {
final Matcher localeMatcher = localeFinder.matcher(path.getFileName().toString());
if (localeMatcher.find()) { // should always be true, since we check beforehand
final String locale = localeMatcher.group(1) == null
? SINGLE_TRUTH_LOCALE
: localeMatcher.group(1);
return locale;
}
throw new IllegalArgumentException("Argument was not a conform resx file");
}
public void register(final OverviewPresenter p) {
presenter = p;
}
public void loadFromDirectory(final Path resxFolder) {
this.currentPath = resxFolder;
translations.clear();
try (Stream<Path> resxFiles = Files.find(resxFolder, 1, (path,
properties) -> path.toString().matches(FILENAME_REGEX),
FileVisitOption.FOLLOW_LINKS)) {
translations.putAll(resxFiles.collect(Collectors.toMap(
OverviewModel::parseFileName, this::parseFile)
));
} catch (IOException ex) {
String errorMessage = String.format(
"Could not access %s due to %s", resxFolder, ex);
System.err.println(errorMessage);
presenter.onException(ex, errorMessage);
}
normalizeDocuments();
presenter.onParseCompletion();
}
private void normalizeDocuments() {
final Set<String> singleTruth = translations
.get(SINGLE_TRUTH_LOCALE)
.getRootElement()
.getChildren(ELEMENT_NAME)
.stream()
.map(el -> el.getAttribute(KEY_NAME).getValue())
.collect(Collectors.toSet());
translations.values().forEach(
doc -> normalizeDocument(doc, singleTruth));
saved.lazySet(false);
}
private void normalizeDocument(final Document doc, final Set<String> singleTruth) {
final List<Element> localeElements = doc.getRootElement().getChildren(ELEMENT_NAME);
Set<String> localeKeys = new HashSet<>();
// remove keys not present in the Single truth
for (Iterator<Element> it = localeElements.iterator(); it.hasNext(); ) {
final Element el = it.next();
if (!singleTruth.contains(el.getAttribute(KEY_NAME).getValue())) {
it.remove();
continue;
}
localeKeys.add(el.getAttribute(KEY_NAME).getValue());
}
singleTruth.stream()
.filter(key -> !localeKeys.contains(key))
.map(OverviewModel::createNewElement)
.forEach(doc.getRootElement()::addContent);
}
private static Element createNewElement(String key) {
Element newElement = new Element(ELEMENT_NAME);
Element valueContainer = new Element(VALUE_NAME);
valueContainer.setText("");
newElement.setAttribute(KEY_NAME, key);
newElement.addContent(valueContainer);
return newElement;
}
private Document parseFile(final Path path) {
final Path xmlFile = path.getFileName();
final SAXBuilder documentBuilder = new SAXBuilder();
final Document doc;
try {
doc = documentBuilder.build(path.toFile());
return doc;
} catch (JDOMException e) {
presenter.onException(e, "Unspecified Parsing error");
throw new IllegalStateException("Unable to parse " + xmlFile, e);
} catch (IOException e) {
presenter.onException(e, "Unspecified I/O Error");
throw new UncheckedIOException("Unable to read" + xmlFile, e);
}
}
public List<Translation> getTranslations(final String locale) {
Document document = translations.get(locale);
final List<Element> translationElements = document.getRootElement()
.getChildren(ELEMENT_NAME);
return translationElements.stream()
.map(el -> new Translation(locale, el))
.sorted(Comparator.comparing(Translation::getKey))
.collect(Collectors.toList());
}
public void updateTranslation(final String locale, final String key,
final String newTranslation) {
Element translationToUpdate = getValueElement(locale, key);
translationToUpdate.setText(newTranslation);
}
private Element getValueElement(final String locale, final String key) {
valueExpression.setVariable("key", key);
return valueExpression.evaluateFirst(translations.get(locale));
}
public void saveAll() {
for (Map.Entry<String, Document> entry : translations.entrySet()) {
final Path outFile = currentPath.resolve(fileNameString(entry
.getKey()));
try (OutputStream outStream = Files.newOutputStream(outFile,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
XML_PRETTY_PRINT.output(entry.getValue(), outStream);
saved.lazySet(true);
} catch (IOException e) {
e.printStackTrace(System.err);
presenter.onException(e, "Could not save File");
}
}
}
private String fileNameString(final String locale) {
return String.format(FILE_NAME_FORMAT, locale.isEmpty() ? "" : "." + locale.toLowerCase());
}
public Translation getSingleTranslation(final String locale,
final String key) {
final String currentValue = getValueElement(locale, key).getText();
return new Translation(locale, key, currentValue);
}
public List<String> getAvailableLocales() {
return new ArrayList<>(translations.keySet());
}
public boolean isNotSaved() {
return !saved.get();
}
}
I left out a few things that are available on github for full executability, namely a small UI helper for common code in the gridbag setup process. Also I left out declarations for Side
, Translation
, the OverviewView
interface and the TranslationPresenter
that's responsible for the actual editing of a translation.
I plan to incorporate the changes suggested here and pack the code into a Jar for simple distribution and use as beta version.
I am especially interested in:
- Separation of concerns between Model, View and Presenter
- Necessity of a proper command line argument parser
- Swing tricks to simplify the View
And as usual: all feedback is appreciated :)