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.

5 comments:

Eyðun Nielsen said...

Hmmm... And here I went on to read the headline as:

Monkey see, monkey barks...

How peculiar? :-)

Jesper Steen Møller said...

Yes, you can read it like that, too. Good thing I didn't make a pun on "monkey doo" instead. :-)

Martin said...

Hi Jesper,

Great script, but is there a reference for the different methods like getLineAtOffset, applyEdit, selectAndReveal?

I am struggling to get started with Eclipse Monkey.

Thanks

Martin

Jesper Steen Møller said...

Hi Martin,

Well, I understand your struggle. A word of advice: Use the Source, Luke!. The Source is powerful with this one...

Seriously, this is an area which would benefit tremendously from some contributions of documentation, and code-cleanup (there are some DOMs in the Dash project that are functionally similar, leftover from earlier refactoring, I guess)

ddb said...

I've been looking for some documentation on using other editors besides editors.activeEditor. Nice to see this post that loops through the editors collection and examines by name.
I'm not sure how to find the docs on the dom or dom api or whatever. I would love to find the syntax to refer to the 'last' editor, as in when you (depending on your key mappings) hit CTRL-Tab to toggle between the activeEditor and the "last/previous" editor... any ideas?
Nice rare nuggest of Eclipse monkey, thanks!
--
Darren