In this article, I will develop the Renraku Blogger step by step. Join me to see how developing Eclipse plugins works.
The blogger opens on files called blog.xml
,
will have a custom editor,
the articles will be shown in the outline,
and the attributes will be shown and edited using the property
sheet. A button will allow
to automatically insert the blog into a template file (local or remote)
and to upload the result to a domain via ftp. The format to be saved is
XML.
First, we need to create our workspace. For that, create a new, blank,
plugin project named renraku.blog
. Make sure it has a Java
nature.
Create a package renraku.blog
.
Then, we need to make sure we have all dependencies met. Edit the plugin.xml
file of the project. It should look like:
<?xml version="1.0" encoding="UTF-8"?>
<plugin id="renraku.blog" name="renraku.blog" version="1.0.0">
<runtime>
<library name="blog.jar">
<export
name="*"/>
</library>
</runtime>
</plugin>
We will need to import some libraries to get the classes we need. So
insert the following just before the closing </plugin>
tag:
<requires>
<import
plugin="org.eclipse.core.resources"/>
<import
plugin="org.eclipse.ui"/>
</requires>
In the file explorer, rightclick the project and choose "Update
Classpath".
You should see a "Required plug-in entries" package in your project. If
not, look into the preferences (Plugin Development Environment ->
Java Build Path ->
Use classpath containers
and Target
Platform -> Select All Plugins
). A Project->Rebuild All
is always a good idea when something seems unstable.
Our data structure is simple: We have one document and n
articles. A
document contains template URL, destination URL, ftp user name and ftp
password. An article is referenced by (user defined) ID and contains
title, link, author, date, section, and summary.
Since we want to display our structure in the outline, we will need a
tree structure. Create a class BlogNode
and make it be derived from DefaultMutableTreeNode
(spares us
the whole parent/children crap). A BlogNode
can be either document or article.
First, make the constructor:
public BlogNode(BlogNode oParent) {
if (null != oParent) {
oParent.add(this);
}
}
The node is a document when the parent is null, an article otherwise. For the document case, we surely want to get the articles.
public BlogNode
getArticle(String sID) {
synchronized (super.children) {
BlogNode oArticle;
for (int iPos = super.children.size() -
1; iPos >= 0; iPos--) {
oArticle = (BlogNode)
super.children.get(iPos);
if
(sID.equals(oArticle.
return oArticle;
}
}
}
return null;
}
And a method to return all the children. We will need that later on for the outline.
public Object[] getChildArray() {
if (null != super.children) {
return super.children.toArray();
}
return new Object[0];
}
For easy identification within the scope of a list, we override the equals()
method:
public boolean equals(Object oVal) {
if (oVal instanceof BlogNode) {
final String sID = (String)
getPropertyValue("id");
return null != sID &&
sID.equals(((BlogNode) oVal).getPropertyValue("id"));
}
return false;
}
Now to more interesting parts. We need to make it suitable for the
property sheet, so we need to implement IPropertySource
.
That has the
additional advantage that we can access the data the same way the
property sheet does. First, we need to be aware of our properties:
PROPERTY_LIST_DOCUMENT = {
"template", "target", "user", "pass" };
PROPERTY_LIST_ARTICLE = { "id", "title", "link", "date",
"author", "section", "summary" };
Then, the usual get and set methods. Use a HashMap
attribute.
public Object getPropertyValue(Object oID) {
return hProp.get(oID);
}
public void setPropertyValue(Object oID, Object oVal) {
hProp.put(oID, oVal);
}
Ignore the rest of the methods; they are not interesting except one: The delivery of the property descriptors. Since we dont have any special requirements for the property sheets table cells, we just take the standard text descriptor.
public IPropertyDescriptor[] getPropertyDescriptors()
{
final String[] asProp = isRoot() ?
PROPERTY_LIST_DOCUMENT : PROPERTY_LIST_ARTICLE;
IPropertyDescriptor[] aoDesc = new
IPropertyDescriptor[asProp.length];
for (int iPos = asProp.length - 1; iPos >= 0;
iPos--) {
aoDesc[iPos] = new
TextPropertyDescriptor(asProp[iPos], asProp[iPos]);
}
return aoDesc;
}
With this, we have a data structure that holds all our needs and is prepared for the property sheet.
I was pondering on whether to use XOM or other XML tools,
but our tool is so simple I would rather go for a quick hack to keep
the example easy. Make a class BlogFactory
. The XML file to
be read typically looks like this:
<?xml version="1.0" encoding="UTF-8"?>
<document template="http://www.land-of-kain.de/index.xml"
target="ftp://www.land-of-kain.de/index.html"
ftpuser="www.land-of-kain.de"
ftppass="haha">
<article id="cpp_sweet_spot">
<title value="The C++ Style Sweet Spot"/>
<link
value="http://
<author value="Kai Ruhl"/>
<date value="2003-10"/>
<section value="software"/>
<summary>Bjarne Stroustrup talks with
Artima about the
perils of staying too low level and venturing too object-oriented in
C++ programming style.</summary>
</article>
</document>
For the blog factory, we make some bold assumptions: Mainly, that every tag has its content in one line, even the description (summary) tag. We also omit any error checking. Now we load the tree:
public static BlogNode loadTree(InputStream
oIn) throws IOException {
...
while (null != sLine &&
!sLine.equals("</document>")) {
// Either the article tag with its ID.
if (sLine.startsWith("<article
")) {
oArticle = new
BlogNode(oRoot);
oArticle.setPropertyValue("id", extractPropertyValue("id", sLine));
}
// Or the article innards with their
value.
else if (null != oArticle &&
sLine.startsWith("\t<")) {
if
(sLine.startsWith("\t<summary>")) {
sTag = "summary";
sVal =
sLine.substring("\t<summary>".length(),
sLine.lastIndexOf("</summary>"));
}
else {
sTag =
sLine.substring(2, sLine.indexOf(" "));
sVal =
extractPropertyValue("value", sLine);
}
oArticle.setPropertyValue(sTag, sVal);
}
sLine = oIn.readLine();
}
...
}
I also wrote an analoguous save method, which traverses the document tree and puts it all into a single string which is the XML file's content. I assume you know how to do this, so I omit it here. Now on to one interesting bit, the editor.
We want to create an editor with multiple pages: One for the articles,
one for the rest of the data. For that, create a class BlogEditor
,
which descends from MultiPageEditorPart
.
Like all new views, it must be
announced to Eclipse via the plugin.xml
file:
<extension point="org.eclipse.ui.editors">
<editor
name="Blog Editor"
icon="icons/blog.gif"
filenames="blog.xml"
class="renraku.blog.BlogEditor"
id="renraku.blog.BlogEditor">
</editor>
</extension>
Now back to the BlogEditor
class.
Leave the generated methods alone at first.
First, we need to fulfill an impicit ViewPart
(which is superclass to MultiPageEditorPart
) contract: To
implement the init()
method in which we load the file.
public void init(IEditorSite oSite, IEditorInput
oInput) throws PartInitException {
super.init(oSite, oInput);
if (oInput instanceof IFileEditorInput) {
// Mandatory initialization (implicit
Eclipse contract).
setSite(oSite);
setInput(oInput);
// Read the file content.
if (null != this.oRoot) {
IFile oFile =
((IFileEditorInput) oInput).getFile();
try {
this.oRoot =
BlogFactory
}
catch (CoreException oExc) {
throw new
PartInitException("Reading blog failed: " + oExc.getMessage());
}
}
}
else {
throw new PartInitException("Invalid
blog: Must be IFileEditorInput.");
}
}
We loaded the tree structure by using BlogFactory.loadTree()
;
the saving process is using BlogFactory.saveTree()
to write the tree as XML. Since in Eclipse files can only be saved when
they are "dirty", make a tDirty
flag attribute; the pages' isDirty()
methods will return it, and we provide a setDirty()
method to
facilitate notification of dirtyness.
public void setDirty(boolean tDirty) {
this.tDirty = tDirty;
firePropertyChange(PROP_DIRTY);
}
And now, the save method: We get the string containing the whole XML
file, put it into a ByteArrayInputStream
that the IFile.setContents()
method can suck out, and send a not-dirty-anymore notification.
public void doSave(IProgressMonitor oMonitor) {
final String sFile = BlogFactory
InputStream oIn = new
ByteArrayInputStream
try {
this.oFile.setContents(oIn, true, true,
oMonitor);
setDirty(false);
}
catch (CoreException oExc) {
System.err.println("Warning: Could not
save file " + oFile.getName());
}
}
Once we have established the I/O stuff, we want to say which pages we wish to be in our multi page editor:
protected void createPages() {
try {
addPage(new EditPage(),
getEditorInput());
setPageText(0, "Blog");
addPage(new UploadPage(),
getEditorInput());
setPageText(1, "Upload");
}
catch (PartInitException oExc) {
System.err.println("Warning: Could not
create blog edit part");
}
}
Which, in turn, has the major disadvantage that we have never heard of
either EditPage
nor UploadPage
. Which means we
must write then.
Make an inner class EditPage
and let it be derived from EditorPart
.
First we need to fulfill the implicit contract:
public void init(IEditorSite oSite,
IEditorInput oInput) throws PartInitException {
this.setSite(oSite);
this.setInput(oInput);
}
As mentioned before, the page must indicate whether it is dirty:
public boolean isDirty() {
return tDirty;
}
Then, we need to create the GUI, which involves a list for the articles, some input fields for the article's details, and an "Add Article" button which also changes to "Mod Article" when an article has been selected from the list.
The GUI is creating by filling the method createPartControl()
with content. We will look only at snippets here.
public void createPartControl(Composite
gParent) {
...
// The article list.
gArticleList = new List(gParent,
SWT.BORDER | SWT.V_SCROLL);
gArticleList
gArticleList
...
// The article details panel.
Composite gArticlePan = new
Composite(gParent, SWT.NONE);
createLabel(gArticlePan, "Article ID");
gArticleID =
createTextField(gArticlePan, 0);
createLabel(gArticlePan, "Title");
gArticleTitle =
createTextField(gArticlePan, 0);
...
// The OK button.
createLabel(gArticlePan, null);
gAddArticle = new Button(gArticlePan,
SWT.FLAT);
gAddArticle.setText("Add Article");
gAddArticle
loadGUI();
}
Where loadGUI()
sets the widgets' values from the model. The
listener oSelectEar
is a SelectionListener
which
handles both the selection of an article (or the "New Article"
placeholder) in the article list and the "Add Article" button.
public void widgetSelected(SelectionEvent
oEvt) {
Object oSrc = oEvt.getSource();
if (oSrc == gArticleList) {
final int iIndex =
gArticleList.getSelectionIndex();
loadArticle(0 == iIndex ?
null : (BlogNode) oRoot.getChildAt(iIndex - 1));
}
else if (oSrc == gAddArticle) {
saveArticle();
}
}
Upon selection in the article list, the selected article will be set into the text fields for the article details:
private void loadArticle(BlogNode
oArticle) {
if (null == oArticle) {
this.oArticle = null;
gArticleID.setText("");
gArticleTitle.setText("");
...
}
else {
this.oArticle = oArticle;
gArticleID
gArticleTitle
...
}
}
Then we have the "Add Article" button. It fulfills three operations: When the "New Article" is selected, it adds an article; if an existing article is selected, it modifies the article; and if the ID field is empty, it deletes the article.
private void saveArticle() {
final String sID = gArticleID.getText();
final boolean tNewArticle = null ==
oArticle;
// Delete article when ID is empty.
if (sID.equals("")) {
if (!tNewArticle) {
oRoot.remove(oArticle);
gArticleList
gArticleList.setSelection(0);
}
}
else {
// Make new article when
there is not one.
if (tNewArticle) {
oArticle = new
BlogNode(oRoot, 0);
oArticle.setPropertyValue("id", sID);
gArticleList.add(sID, 1);
}
// Modify article.
oArticle.setPropertyValue("title", gArticleTitle.getText());
oArticle.setPropertyValue("link", gArticleLink.getText());
oArticle.setPropertyValue("date", gArticleDate.getText());
oArticle.setPropertyValue("author", gArticleAuthor.getText());
oArticle.setPropertyValue("section", gArticleSection.getText());
oArticle.setPropertyValue("summary", gArticleDesc.getText());
// Set index to new
article when it is new.
if (tNewArticle) {
gArticleList.setSelection(1);
loadArticle(oArticle);
}
}
// In any case, flag as dirty.
setDirty(true);
}
With that, we have the complete lifecycle of the edit page.
The upload page is quite simple: One text field for each of the four document attributes: FTP user and password, the URL (local or remote) for the template HTML file, and the FTP target.
As for the edit page, make the inner class UploadPage
derived
from EditorPart
, fulfill the init()
contract,
implement isDirty()
and then move on to the GUI:
public void createPartControl(Composite
gParent) {
GridLayout oGridLayout = new
GridLayout(2, false);
gParent.setLayout(oGridLayout);
createLabel(gParent, "FTP User");
gUser = createFocusTextField(gParent, 0,
oFocusEar);
createLabel(gParent, "FTP Pass");
gPass = createFocusTextField(gParent,
SWT.PASSWORD, oFocusEar);
createLabel(gParent, "Template URL");
gTemplate =
createFocusTextField(gParent, 0, oFocusEar);
createLabel(gParent, "Target URL");
gTarget = createFocusTextField(gParent,
0, oFocusEar);
createLabel(gParent, null);
gUpload = new Button(gParent, SWT.FLAT);
gUpload.setText("Upload");
gUpload
loadGUI();
}
This time, we want to save a text fields content as soon as it loses
focus, and we do so with a FocusListener
on each of the
textfields. The listener method is focusLost()
, the other is
only a helper method.
public void focusLost(FocusEvent oEvt) {
Object oSrc = oEvt.getSource();
if (oSrc == gUser) {
handleFocusLost("user",
gUser);
}
else if (oSrc == gPass) {
handleFocusLost("pass",
gPass);
}
...
}
private void handleFocusLost(String sProp,
Text gText) {
String sOld = (String)
oRoot.getPropertyValue(sProp);
String sNew = gText.getText();
if (!sNew.equals(sOld)) {
oRoot.setPropertyValue(sProp, sNew);
setDirty(true);
}
}
The upload button is the last thing to do: Its SelectionListener
calls
for the XML factory to assemble and upload the data.
public void widgetSelected(SelectionEvent
oEvt) {
gUpload.setEnabled(false);
Shell gShell = getSite()
try {
BlogFactory
MessageDialog
}
catch (IOException oExc) {
MessageDialog
}
gUpload.setEnabled(true);
}
During the upload, we disable the "Upload" button; afterwards, we show
a dialog with some result info. While BlogFactory.upload()
may be an interesting method, it
has nothing to do with Eclipse handling, so we omit it here.
We now have a fully functional blogger, but without outline and
property sheet. We will now have a look at how to implement the outline
utilizing the standard Eclipse outline view. We tell BlogNode
to implement the interface IAdaptable
, which means we must
supply an adapter if asked:
public Object getAdapter(Class oClass) {
if (oClass
return oWorkbenchAdapter;
}
return null;
}
Obviously, we do not have a workbench adapter
yet. Create an attribute oWorkbenchAdapter
to be of class IWorkbenchAdapter
,
and then make sure it is initalized in the constructor with the
following inner class:
public static class BlogWorkbenchAdapter implements
IWorkbenchAdapter {
public Object[] getChildren(Object oVal) {
if (oVal instanceof BlogNode) {
return ((BlogNode)
oVal).getChildArray();
}
return new Object[0];
}
public Object getParent(Object
oVal) {
if (oVal instanceof BlogNode) {
return ((BlogNode)
oVal).getParent();
}
return null;
}
public ImageDescriptor getImageDescriptor(Object
oVal) {
return null;
}
public String getLabel(Object oVal) {
if (oVal instanceof BlogNode) {
BlogNode oNode = (BlogNode)
oVal;
return
oNode.getPropertyValue(oNode.isRoot() ? "user" : "id").toString();
}
return null == oVal ? "null" :
oVal.toString();
}
}
The children and parent are returned from the node, we discard the images, and concentrate on the label that we want to appear in the outline view. For articles, we take its ID, for the document (somewhat random) the ftp user.
The next step is to specify the outline page that we want to
display. For this, we enter BlogEditor
and override its getAdapter()
method. Again, we will only supply an adapter when asked for the
content outline page, and only if the input is the blog file.
public Object getAdapter(Class oClass) {
// Does the workbench want a content outline page?
if (oClass.equals
IEditorInput oInput = getEditorInput();
if (oInput instanceof IFileEditorInput) {
if (null ==
this.oOutlinePage) {
this.oOutlinePage = new OutlinePage();
}
return this.oOutlinePage;
}
}
// Or something else?
return super.getAdapter(oClass);
}
Which brings us to the point that we need to supply an outline page.
Still inside BlogEditor
, create an inner class OutlinePage
derived from ContentOutlinePage
and override its GUI
creation method:
public void createControl(Composite
gParent) {
super.createControl(gParent);
TreeViewer oTreeView =
super.getTreeViewer();
oTreeView
oTreeView
oTreeView.setInput(oRoot);
}
Thats about it! On the next start of the plugin, we should have the outline filled with the articles.
Nothing to do. That's right, you heard me. We let BlogNode
implement IPropertySource
, and the standard WorkbenchPart.getAdapter()
method that is in the superclass of our BlogEditor
delivers
the appropriate workbench adapter. So, when we click an article in the
outline, we automagically have it in the property sheet (if not: Window
-> Show View -> Other -> Basic -> Properties
).
We have seen how to build a tree model in a way that it can be used for the property sheet, how to implement the interfaces and adapters to use an outline page, and how to build a custom editor with multiple pages.
What we did not handle is synchronization between property sheet and
editor, tolerant XML handling in the BlogFactory
, a "New
Blog" Wizard, XML source page and HTML preview page in the BlogEditor
,
and I am sure when I think hard enough I can imagine a lot of other
goodies.
Hopefully you learned something in our progress here. Goodbye!
EOF (Jul:2005)