Wednesday, August 8, 2007

Monkey see, abe gør

(Abe gør = monkey do, in Danish)

I'm lazy, sure, no denying that. Sometimes I'm a bit too lazy, especially when programming -- and a while ago, I was too lazy to extract all the strings of a web application into a message bundle. Anyway, the application was only going to be in one language, so why bother?

Then, when scope crept in on me, and the inevitable second language had to be added, I suddenly had ~45 JSP/facelet pages to go through and extract all the strings. And, this being a hobby effort, I had no other developers to pass the tedius jobs on to.

The procedure is as follows:
* Go to the target JSP page
* Select the text to externalize
* Cut
* Switch to the resource bundle's text editor
* Enter the desired message key name
* Paste the text
* Copy the chosen message key name
* Switch back to the JSP page
* Enter the text output text boilerplate code and the key name, such as #{messages['LoginPage.label.userName']}

All in all, it added up to about 400 strings! No way I was going to do that by hand. Enter Eclipse Monkey, a "macro" system for Eclipse, where you can run JavaScript scripts (or other engines) within Eclipse without having to deal with making your own plugins and whatnot. But building a "localization macro" with Eclipse Monkey was harder than I thought, but then again it was my first ever script for the Monkey.

First up was the choice of which monkey DOMs I had available. A Monkey DOM makes one or more objects available to the scripting context, serving as an interaction point into Eclipse. You may also reference classes directly, but only in a few select packages (due to the access restrictions managed by OSGi). Although I was tempted to write one myself, I could make do with one of the builtin DOMs (org.eclipse.eclipsemonkey.lang.javascript), since I only needed access to the text editors, as in the variable "editors" below.

Second was the need for configurability. My Monkey script needed to know which file to copy the lozalized string to, but I have yet to find a mechanism for storing user-specific script parameters other than in the script itself. So I put the filename into a variable (destinationFileName), easy to adjust for anyone. The replacement template is specific to JBoss Seam, you can also tailor this to your need.

Third, I chose a viable keyboard shortcut, and out the file into the scripts directory in my project. The rest, as they say, is just code:


--- Came wiffling through the eclipsey wood ---
/*
* Menu: Localization > Extract String
* Key: SHIFT+F12
* Kudos: Jesper Steen Møller, Paul Colton (Aptana, Inc.)
* License: EPL 1.0
* DOM: http://download.eclipse.org/technology/dash/update/org.eclipse.eclipsemonkey.lang.javascript
*/

function askKey(oldKey, targetString) {
dialog = new Packages.org.eclipse.jface.dialogs.InputDialog(
window.getShell(),
"Localization Key",
"Enter the localization key for string '" + targetString + "' ?",
oldKey, null)
result = dialog.open()
if (result == Packages.org.eclipse.jface.window.Window.OK) {
return dialog.getValue()
}
}
// Simple info dialog
function show(text) {
Packages.org.eclipse.jface.dialogs.MessageDialog.openInformation(
window.getShell(),
"Extract String",
text
)
}
// Simple error dialog
function showError(text) {
Packages.org.eclipse.jface.dialogs.MessageDialog.openError(
window.getShell(),
"Extract String",
text
)
}

function main() {
// Change this for using a different editor source
var destinationFileName = "messages_da.properties";
var destinationEditor = undefined;
var sourceEditor = editors.activeEditor;
var valid = false;
for each( editor in editors.all ) {
if (editor.title == destinationFileName) {
destinationEditor = editor;
valid = true;
}
}
if (! valid) {
showError("No editor is open for " + destinationFileName);
}
if (valid && (sourceEditor == null)) {
valid = false;
showError("No active editor");
}
// make sure we have an editor
if (valid && (sourceEditor === undefined)) {
valid = false;
showError("No active editor");
}
// insert replacement
if (valid) {
var range = sourceEditor.selectionRange;
var offset = range.startingOffset;
var deleteLength = range.endingOffset - range.startingOffset;
var source = sourceEditor.source;
var replacement = source.substr(range.startingOffset, deleteLength);

replacement = replacement.replace(/(\r)?\n/g, "\\n\\" + destinationEditor.lineDelimiter + " ");

var keyLine = findKeyLineNo(destinationEditor);
var oldKeyName = "key" + keyLine;

if (keyLine >=0 ) {
var theLine = lineContents(destinationEditor, keyLine);
oldKeyName = theLine.substring(0, theLine.indexOf('='));
}

var key = askKey(oldKeyName, replacement);
if (! (key === undefined)) {
var text = "#{messages['" + key + "']}";

// apply edit and reveal in editor
sourceEditor.applyEdit(offset, deleteLength, text);
sourceEditor.selectAndReveal(offset, text.length);

// now copy the replacement text into the property file
var curLine = destinationEditor.getLineAtOffset(destinationEditor.selectionRange.endingOffset);
var replacementOffset = destinationEditor.getOffsetAtLine(curLine+1);
if (replacementOffset < 1) replacementOffset = destinationEditor.sourceLength;
var replacementText = key + "=" + replacement + destinationEditor.lineDelimiter;
destinationEditor.applyEdit(replacementOffset, 0, replacementText);
destinationEditor.selectAndReveal(replacementOffset, replacementText.length);
}
}
}
// Get the string contents of a line
function lineContents(anEditor, lineNo) {
var firstOffset = anEditor.getOffsetAtLine(lineNo);
var lastOffset = anEditor.getOffsetAtLine(lineNo + 1);
var s = anEditor.source.substr(firstOffset, lastOffset - firstOffset);
//alert("Contents of line " + lineNo + ":" + s + " (" + firstOffset + "," + lastOffset + ")");
return s;
}
// Finds the nearest line which starts a key=value
function findKeyLineNo(anEditor) {
var beginLine = anEditor.getLineAtOffset(anEditor.selectionRange.startingOffset);

// Search up...
var i = beginLine;
while (i >= 0) {
if (lineContents(anEditor, i).indexOf('=') > 0) return i;
--i;
}
var lastLineNo = anEditor.getLineAtOffset(anEditor.sourceLength);
var i = beginLine + 1;
while (i < lastLineNo) {
if (lineContents(anEditor, i).indexOf('=') > 0) return i;
++i;
}
return -1;
}
--- And burbled as it ran! ---


The funny header and footer makes it easy to paste the script into you Eclipse workspace, just mark everything (including the headers) and choose Scripts > Paste New Script, and you're flying.

The morale is: Look for script that do similar things, and experiment from there.