Eclipse Plugin Tutorial

Abstract

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.

Jump Start

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.

Data Structure & Property Sheet

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.getPropertyValue("id"))) {
          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.

The XML Factory

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://www.artima.com/intv/goldilocks.html"/>
    <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.

The Custom 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.loadTree(oFile.getContents());
        }
        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.saveTree(oRoot);
    InputStream oIn = new ByteArrayInputStream(sFile.getBytes());
    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.

The Custom Editor: Edit Page

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.

Edit Page
Edit Page

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.setItems(new String[] { "<new article>" });
      gArticleList.addSelectionListener(oSelectEar);
      ...
      // 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.addSelectionListener(oSelectEar);
      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.setText(oArticle.getPropertyValue("id").toString());
        gArticleTitle.setText(oArticle.getPropertyValue("title").toString());
        ...
      }
    }

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.remove(oArticle.getPropertyValue("id").toString());
          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 Custom Editor: Upload 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.

Upload Page
Upload Page

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.addSelectionListener(oSelectEar);
      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().getShell();
      try {
        BlogFactory.upload(oRoot);
        MessageDialog.openInformation(gShell, "Done", "Upload successful.");
      }
      catch (IOException oExc) {
        MessageDialog.openInformation(gShell, "Failed", oExc.getMessage());
      }
      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.

The Outline

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.equals(IWorkbenchAdapter.class)) {
      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(IContentOutlinePage.class)) {
      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.setContentProvider(new WorkbenchContentProvider());
      oTreeView.setLabelProvider(new WorkbenchLabelProvider());
      oTreeView.setInput(oRoot);
    }

Thats about it! On the next start of the plugin, we should have the outline filled with the articles.

The Property Editor

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).

Conclusion

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)