FELIX-1051 Add Localization Support based on Bundle-Localization header. Also included is a first shot at trying to implement some templating support. See issue for details.

git-svn-id: https://svn.apache.org/repos/asf/felix/trunk@902097 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/AbstractWebConsolePlugin.java b/webconsole/src/main/java/org/apache/felix/webconsole/AbstractWebConsolePlugin.java
index 12281af..077924a 100644
--- a/webconsole/src/main/java/org/apache/felix/webconsole/AbstractWebConsolePlugin.java
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/AbstractWebConsolePlugin.java
@@ -32,7 +32,7 @@
 import org.apache.commons.fileupload.disk.DiskFileItemFactory;
 import org.apache.commons.fileupload.servlet.ServletFileUpload;
 import org.apache.commons.fileupload.servlet.ServletRequestContext;
-import org.apache.felix.webconsole.internal.WebConsolePluginAdapter;
+import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleContext;
 import org.osgi.framework.Constants;
 
@@ -186,6 +186,12 @@
     }
 
 
+    /**
+     * Returns the <code>BundleContext</code> with which this plugin has been
+     * activated. If the plugin has not be activated by calling the
+     * {@link #activate(BundleContext)} method, this method returns
+     * <code>null</code>.
+     */
     protected BundleContext getBundleContext()
     {
         return bundleContext;
@@ -193,6 +199,20 @@
 
 
     /**
+     * Returns the <code>Bundle</code> pertaining to the
+     * {@link #getBundleContext() bundle context} with which this plugin has
+     * been activated. If the plugin has not be activated by calling the
+     * {@link #activate(BundleContext)} method, this method returns
+     * <code>null</code>.
+     */
+    public final Bundle getBundle()
+    {
+        final BundleContext bundleContext = getBundleContext();
+        return ( bundleContext != null ) ? bundleContext.getBundle() : null;
+    }
+
+
+    /**
      * Returns the object which might provide resources. The class of this
      * object is used to find the <code>getResource</code> method.
      * <p>
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/VariableResolver.java b/webconsole/src/main/java/org/apache/felix/webconsole/VariableResolver.java
new file mode 100644
index 0000000..cc50236
--- /dev/null
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/VariableResolver.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.felix.webconsole;
+
+
+/**
+ * The <code>VariableResolver</code> interface is a very simple interface which
+ * may be implemented by Web Console plugins to provide replacement values for
+ * variables in the generated content.
+ * <p>
+ * The main use of such a variable resolve is when a plugin is using a static
+ * template which provides slots to place dynamically generated content
+ * parts.
+ */
+public interface VariableResolver
+{
+
+    /**
+     * Default implementation of the {@link VariableResolver} interface whose
+     * {@link #get(String)} method always returns <code>null</code>.
+     */
+    VariableResolver DEFAULT = new VariableResolver()
+    {
+        public String get( String variable )
+        {
+            return null;
+        }
+    };
+
+
+    /**
+     * Return a replacement value for the named variable or <code>null</code>
+     * if no replacement is available.
+     *
+     * @param variable The name of the variable for which to return a
+     *      replacement.
+     * @return The replacement value or <code>null</code> if no replacement is
+     *      available.
+     */
+    String get( String variable );
+
+}
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/internal/WebConsolePluginAdapter.java b/webconsole/src/main/java/org/apache/felix/webconsole/internal/WebConsolePluginAdapter.java
index 2a79580..4d0b10c 100644
--- a/webconsole/src/main/java/org/apache/felix/webconsole/internal/WebConsolePluginAdapter.java
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/internal/WebConsolePluginAdapter.java
@@ -20,6 +20,7 @@
 
 
 import java.io.IOException;
+import java.lang.reflect.Method;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Iterator;
@@ -29,6 +30,7 @@
 import javax.servlet.http.HttpServletResponse;
 
 import org.apache.felix.webconsole.AbstractWebConsolePlugin;
+import org.apache.felix.webconsole.VariableResolver;
 import org.apache.felix.webconsole.WebConsoleConstants;
 import org.osgi.framework.ServiceReference;
 
@@ -39,7 +41,7 @@
  * {@link org.apache.felix.webconsole.WebConsoleConstants#PLUGIN_TITLE}
  * service attribute.
  */
-public class WebConsolePluginAdapter extends AbstractWebConsolePlugin
+public class WebConsolePluginAdapter extends AbstractWebConsolePlugin implements VariableResolver
 {
 
     /** serial UID */
@@ -57,6 +59,9 @@
     // the CSS references (null if none)
     private final String[] cssReferences;
 
+    // the delegatee variable resolver
+    private VariableResolver varResolver;
+
 
     public WebConsolePluginAdapter( String label, String title, Servlet plugin, ServiceReference serviceReference )
     {
@@ -64,6 +69,9 @@
         this.title = title;
         this.plugin = plugin;
         this.cssReferences = toStringArray( serviceReference.getProperty( WebConsoleConstants.PLUGIN_CSS_REFERENCES ) );
+
+        // activate this abstract plugin (mainly to set the bundle context)
+        activate( serviceReference.getBundle().getBundleContext() );
     }
 
 
@@ -127,13 +135,28 @@
      */
     public void init( ServletConfig config ) throws ServletException
     {
-        // base classe initialization
-        super.init( config );
+        // no need to activate the plugin, this has already been done
+        // when the instance was setup
+        try
+        {
+            // base classe initialization
+            super.init( config );
 
-        // plugin initialization
-        plugin.init( config );
+            // plugin initialization
+            plugin.init( config );
+        }
+        catch ( ServletException se )
+        {
+            // if init fails, the plugin will not be destroyed and thus
+            // the plugin not deactivated. Do it here
+            deactivate();
+
+            // rethrow the exception
+            throw se;
+        }
     }
 
+
     /**
      * Detects whether this request is intended to have the headers and
      * footers of this plugin be rendered or not. The decision is taken based
@@ -178,11 +201,26 @@
      */
     public void destroy()
     {
-        plugin.destroy();
-        super.destroy();
+        try
+        {
+            plugin.destroy();
+            super.destroy();
+        }
+        finally
+        {
+            varResolver = null;
+            deactivate();
+        }
     }
 
 
+    //---------- VariableResolver
+
+    public String get( String variable )
+    {
+        return getVariableResolver().get(variable);
+    }
+
     //---------- internal
 
     private String[] toStringArray( final Object value )
@@ -222,4 +260,69 @@
 
         return null;
     }
+
+
+    private VariableResolver getVariableResolver()
+    {
+        if ( varResolver == null )
+        {
+            if ( plugin instanceof VariableResolver )
+            {
+                varResolver = ( VariableResolver ) plugin;
+            }
+            else
+            {
+                varResolver = VariableResolverProxy.create( plugin );
+            }
+        }
+
+        return varResolver;
+    }
+
+    private static class VariableResolverProxy implements VariableResolver
+    {
+        static VariableResolver create( Object object )
+        {
+            try
+            {
+                final Class stringClass = String.class;
+                final Method getMethod = object.getClass().getMethod( "get", new Class[]
+                    { stringClass } );
+                if ( getMethod.getReturnType() == stringClass )
+                {
+                    return new VariableResolverProxy( object, getMethod );
+                }
+            }
+            catch ( Throwable t )
+            {
+            }
+
+            return VariableResolver.DEFAULT;
+        }
+
+        private final Object object;
+
+        private final Method getMethod;
+
+
+        private VariableResolverProxy( final Object object, final Method getMethod )
+        {
+            this.object = object;
+            this.getMethod = getMethod;
+        }
+
+
+        public String get( String variable )
+        {
+            try
+            {
+                return ( String ) getMethod.invoke( object, new Object[]
+                    { variable } );
+            }
+            catch ( Throwable t )
+            {
+                return null;
+            }
+        }
+    }
 }
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/internal/filter/FilteringResponseWrapper.java b/webconsole/src/main/java/org/apache/felix/webconsole/internal/filter/FilteringResponseWrapper.java
new file mode 100644
index 0000000..3b2885e
--- /dev/null
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/internal/filter/FilteringResponseWrapper.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.felix.webconsole.internal.filter;
+
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ResourceBundle;
+
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+
+import org.apache.felix.webconsole.VariableResolver;
+
+
+/**
+ * The <code>FilteringResponseWrapper</code> wraps the response to provide a
+ * filtering writer for HTML responses. The filtering writer filters the
+ * response such that any string of the form <code>${some text}</code> is
+ * replaced by a translation of the <i>some text</i> according to the
+ * <code>ResourceBundle</code> provided in the constructor. If no translation
+ * exists in the resource bundle, the text is written unmodifed (except the
+ * wrapping <code>${}</code> characters are removed.
+ */
+public class FilteringResponseWrapper extends HttpServletResponseWrapper
+{
+
+    // the resource bundle providing translations for the output
+    private final ResourceBundle locale;
+
+    private final VariableResolver variables;
+
+    // the writer sending output in this response
+    private PrintWriter writer;
+
+
+    /**
+     * Creates a wrapper instance using the given resource bundle for
+     * translations.
+     */
+    public FilteringResponseWrapper( HttpServletResponse response, ResourceBundle locale, VariableResolver variables )
+    {
+        super( response );
+        this.locale = locale;
+        this.variables = variables;
+    }
+
+
+    /**
+     * Returns a <code>PrintWriter</code> for the response. If <code>text/html</code>
+     * is being generated a filtering writer is returned which translates
+     * strings enclosed in <code>${}</code> according to the resource bundle
+     * configured for this response.
+     */
+    public PrintWriter getWriter() throws IOException
+    {
+        if ( writer == null )
+        {
+            final PrintWriter base = super.getWriter();
+            if ( doWrap() )
+            {
+                final ResourceFilteringWriter filter = new ResourceFilteringWriter( base, locale, variables );
+                writer = new PrintWriter( filter );
+            }
+            else
+            {
+                writer = base;
+            }
+        }
+
+        return writer;
+    }
+
+
+    private boolean doWrap()
+    {
+        boolean doWrap = getContentType() != null && getContentType().indexOf( "text/html" ) >= 0;
+        return doWrap;
+    }
+
+}
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/internal/filter/ResourceFilteringWriter.java b/webconsole/src/main/java/org/apache/felix/webconsole/internal/filter/ResourceFilteringWriter.java
new file mode 100644
index 0000000..ed38cdf
--- /dev/null
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/internal/filter/ResourceFilteringWriter.java
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.felix.webconsole.internal.filter;
+
+
+import java.io.FilterWriter;
+import java.io.IOException;
+import java.io.Writer;
+import java.util.MissingResourceException;
+import java.util.ResourceBundle;
+
+import org.apache.felix.webconsole.VariableResolver;
+
+
+/**
+ * The <code>ResourceFilteringWriter</code> is a writer, which translates
+ * strings of the form <code>${some key text}</code> to a translation
+ * of the respective <i>some key text</i> or to the <i>some key text</i>
+ * itself if no translation is available from a resource bundle.
+ */
+class ResourceFilteringWriter extends FilterWriter
+{
+
+    /**
+     * normal processing state, $ signs are recognized here
+     * proceeds to {@link #STATE_DOLLAR} if a $ sign is encountered
+     * proceeds to {@link #STATE_ESCAPE} if a \ sign is encountered
+     * otherwise just writes the character
+     */
+    private static final int STATE_NULL = 0;
+
+    /**
+     * State after a $ sign has been recognized
+     * proceeds to {@value #STATE_BUFFERING} if a { sign is encountered
+     * otherwise proceeds to {@link #STATE_NULL} and writes the $ sign and
+     * the current character
+     */
+    private static final int STATE_DOLLAR = 1;
+
+    /**
+     * buffers characters until a } is encountered
+     * proceeds to {@link #STATE_NULL} if a } sign is encountered and
+     * translates and writes buffered text before returning
+     * otherwise collects characters to gather the translation key
+     */
+    private static final int STATE_BUFFERING = 2;
+
+    /**
+     * escaping the next character, if the character is a $ sign, the
+     * $ sign is writeted. otherwise the \ and the next character is
+     * written
+     * proceeds to {@link #STATE_NULL}
+     */
+    private static final int STATE_ESCAPE = 3;
+
+    /**
+     * The ResourceBundle used for translation
+     */
+    private final ResourceBundle locale;
+
+    private final VariableResolver variables;
+
+    /**
+     * The buffer to gather the text to be translated
+     */
+    private final StringBuffer lineBuffer = new StringBuffer();
+
+    /**
+     * The current state, starts with {@link #STATE_NULL}
+     */
+    private int state = STATE_NULL;
+
+
+    ResourceFilteringWriter( Writer out, ResourceBundle locale, final VariableResolver variables )
+    {
+        super( out );
+        this.locale = locale;
+        this.variables = ( variables != null ) ? variables : VariableResolver.DEFAULT;
+    }
+
+
+    /**
+     * Write a single character following the state machine:
+     * <table>
+     * <tr><th>State</th><th>Character</th><th>Task</th><th>Next State</th></tr>
+     * <tr><td>NULL</td><td>$</td><td>&nbsp;</td><td>DOLLAR</td></tr>
+     * <tr><td>NULL</td><td>\</td><td>&nbsp;</td><td>ESCAPE</td></tr>
+     * <tr><td>NULL</td><td>any</td><td>write c</td><td>NULL</td></tr>
+     * <tr><td>DOLLAR</td><td>{</td><td>&nbsp;</td><td>BUFFERING</td></tr>
+     * <tr><td>DOLLAR</td><td>any</td><td>write $ and c</td><td>NULL</td></tr>
+     * <tr><td>BUFFERING</td><td>}</td><td>translate and write translation</td><td>NULL</td></tr>
+     * <tr><td>BUFFERING</td><td>any</td><td>buffer c</td><td>BUFFERING</td></tr>
+     * <tr><td>ESACPE</td><td>$</td><td>write $</td><td>NULL</td></tr>
+     * <tr><td>ESCAPE</td><td>any</td><td>write \ and c</td><td>NULL</td></tr>
+     * </table>
+     *
+     * @exception IOException If an I/O error occurs
+     */
+    public void write( int c ) throws IOException
+    {
+        switch ( state )
+        {
+            case STATE_NULL:
+                if ( c == '$' )
+                {
+                    state = STATE_DOLLAR;
+                }
+                else if ( c == '\\' )
+                {
+                    state = STATE_ESCAPE;
+                }
+                else
+                {
+                    out.write( c );
+                }
+                break;
+
+            case STATE_DOLLAR:
+                if ( c == '{' )
+                {
+                    state = STATE_BUFFERING;
+                }
+                else
+                {
+                    out.write( '$' );
+                    out.write( c );
+                }
+                break;
+
+            case STATE_BUFFERING:
+                if ( c == '}' )
+                {
+                    state = STATE_NULL;
+                    super.write( translate() );
+                }
+                else
+                {
+                    lineBuffer.append( ( char ) c );
+                }
+                break;
+
+            case STATE_ESCAPE:
+                state = STATE_NULL;
+                if ( c != '$' )
+                {
+                    out.write( '\\' );
+                }
+                out.write( c );
+        }
+    }
+
+
+    /**
+     * Writes each character calling {@link #write(int)}
+     *
+     * @param cbuf Buffer of characters to be written
+     * @param off Offset from which to start reading characters
+     * @param len Number of characters to be written
+     * @exception IOException If an I/O error occurs
+     */
+    public void write( char cbuf[], int off, int len ) throws IOException
+    {
+        final int limit = off + len;
+        for ( int i = off; i < limit; i++ )
+        {
+            write( cbuf[i] );
+        }
+    }
+
+
+    /**
+     * Writes each character calling {@link #write(int)}
+     *
+     * @param str String to be written
+     * @param off Offset from which to start reading characters
+     * @param len Number of characters to be written
+     * @exception IOException If an I/O error occurs
+     */
+    public void write( String str, int off, int len ) throws IOException
+    {
+        final int limit = off + len;
+        for ( int i = off; i < limit; i++ )
+        {
+            write( str.charAt( i ) );
+        }
+    }
+
+
+    /**
+     * Translates the current buffer contents and returns the translation.
+     * First the name is looked up in the variables, if not found the
+     * resource bundle is queried. If still not found, the buffer contents
+     * is returned unmodified.
+     */
+    private String translate()
+    {
+        final String key = lineBuffer.toString();
+        lineBuffer.delete( 0, lineBuffer.length() );
+
+        String value = variables.get( key );
+        if ( value == null )
+        {
+            try
+            {
+                value = locale.getString( key );
+            }
+            catch ( MissingResourceException mre )
+            {
+                // ignore and write the key as the value
+                value = key;
+            }
+        }
+
+        return value;
+    }
+}
\ No newline at end of file
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/CombinedEnumeration.java b/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/CombinedEnumeration.java
new file mode 100644
index 0000000..5d0441b
--- /dev/null
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/CombinedEnumeration.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.felix.webconsole.internal.i18n;
+
+
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.NoSuchElementException;
+import java.util.Set;
+
+
+/**
+ * The <code>CombinedEnumeration</code> combines two enumerations into a single
+ * one first returning everything from the first enumeration and then from the
+ * second enumeration with a single limitation: entries are only returned once.
+ * So if both enumerations would produce the same result, say "123", only the
+ * first would be returned.
+ */
+class CombinedEnumeration implements Enumeration
+{
+
+    // the first enumeration to iterate
+    private final Enumeration first;
+
+    // the second enumeration to iterate once the first is exhausted
+    private final Enumeration second;
+
+    // the set of values already returned to prevent duplicate entries
+    private final Set seenKeys;
+
+    // preview to the next return value for nextElement(), null at the end
+    private Object nextKey;
+
+
+    CombinedEnumeration( final Enumeration first, final Enumeration second )
+    {
+        this.first = first;
+        this.second = second;
+
+        this.seenKeys = new HashSet();
+        this.nextKey = seek();
+    }
+
+
+    public boolean hasMoreElements()
+    {
+        return nextKey != null;
+    }
+
+
+    public Object nextElement()
+    {
+        if ( !hasMoreElements() )
+        {
+            throw new NoSuchElementException();
+        }
+
+        Object result = nextKey;
+        nextKey = seek();
+        return result;
+    }
+
+
+    /**
+     * Check the enumerations for the next element to return. If no more
+     * (unique) element is available, null is returned. The element returned
+     * is also added to the set of seen elements to prevent duplicate provision
+     */
+    private Object seek()
+    {
+        while ( first.hasMoreElements() )
+        {
+            final Object next = first.nextElement();
+            if ( !seenKeys.contains( next ) )
+            {
+                seenKeys.add( next );
+                return next;
+            }
+        }
+        while ( second.hasMoreElements() )
+        {
+            final Object next = second.nextElement();
+            if ( !seenKeys.contains( next ) )
+            {
+                seenKeys.add( next );
+                return next;
+            }
+        }
+        return null;
+    }
+}
\ No newline at end of file
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/CombinedResourceBundle.java b/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/CombinedResourceBundle.java
new file mode 100644
index 0000000..6b6c44c
--- /dev/null
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/CombinedResourceBundle.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.felix.webconsole.internal.i18n;
+
+
+import java.util.Enumeration;
+import java.util.MissingResourceException;
+import java.util.ResourceBundle;
+
+
+class CombinedResourceBundle extends ResourceBundle
+{
+
+    private final ResourceBundle resourceBundle;
+    private final ResourceBundle defaultResourceBundle;
+
+
+    CombinedResourceBundle( final ResourceBundle resourceBundle, final ResourceBundle defaultResourceBundle )
+    {
+        this.resourceBundle = resourceBundle;
+        this.defaultResourceBundle = defaultResourceBundle;
+    }
+
+
+    public Enumeration getKeys()
+    {
+        return new CombinedEnumeration( resourceBundle.getKeys(), defaultResourceBundle.getKeys() );
+    }
+
+
+    protected Object handleGetObject( String key )
+    {
+        // check primary resource bundle first
+        try
+        {
+            return resourceBundle.getObject( key );
+        }
+        catch ( MissingResourceException mre )
+        {
+            // ignore
+        }
+
+        // now check the default resource bundle
+        try
+        {
+            return defaultResourceBundle.getObject( key );
+        }
+        catch ( MissingResourceException mre )
+        {
+            // ignore
+        }
+
+        // finally fall back to using the key
+        return key;
+    }
+
+}
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/ConsolePropertyResourceBundle.java b/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/ConsolePropertyResourceBundle.java
new file mode 100644
index 0000000..e73d482
--- /dev/null
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/ConsolePropertyResourceBundle.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.felix.webconsole.internal.i18n;
+
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.Enumeration;
+import java.util.Properties;
+import java.util.ResourceBundle;
+
+
+class ConsolePropertyResourceBundle extends ResourceBundle
+{
+
+    private final Properties props;
+
+
+    ConsolePropertyResourceBundle( final ResourceBundle parent, final URL source )
+    {
+        setParent( parent );
+
+        props = new Properties();
+        if ( source != null )
+        {
+            InputStream ins = null;
+            try
+            {
+                ins = source.openStream();
+                props.load( ins );
+            }
+            catch ( IOException ignore )
+            {
+            }
+            finally
+            {
+                if ( ins != null )
+                {
+                    try
+                    {
+                        ins.close();
+                    }
+                    catch ( IOException ignore )
+                    {
+                    }
+                }
+            }
+
+        }
+    }
+
+
+    public Enumeration getKeys()
+    {
+        return new CombinedEnumeration( props.keys(), parent.getKeys() );
+    }
+
+
+    protected Object handleGetObject( String key )
+    {
+        return props.get( key );
+    }
+}
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/ResourceBundleCache.java b/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/ResourceBundleCache.java
new file mode 100644
index 0000000..5bf3f91
--- /dev/null
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/ResourceBundleCache.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.felix.webconsole.internal.i18n;
+
+
+import java.net.URL;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.ResourceBundle;
+
+import org.osgi.framework.Bundle;
+import org.osgi.framework.Constants;
+
+
+/**
+ * The <code>ResourceBundleCache</code> caches resource bundles per OSGi bundle.
+ */
+class ResourceBundleCache
+{
+
+    private static final Locale DEFAULT_LOCALE = new Locale( "" );
+
+    private final Bundle bundle;
+
+    private final Map resourceBundles;
+
+    private Map resourceBundleEntries;
+
+
+    ResourceBundleCache( final Bundle bundle )
+    {
+        this.bundle = bundle;
+        this.resourceBundles = new HashMap();
+    }
+
+
+    ResourceBundle getResourceBundle( final Locale locale )
+    {
+        if ( locale == null )
+        {
+            return getResourceBundleInternal( DEFAULT_LOCALE );
+        }
+
+        return getResourceBundleInternal( locale );
+    }
+
+
+    ResourceBundle getResourceBundleInternal( final Locale locale )
+    {
+        if ( locale == null )
+        {
+            return null;
+        }
+
+        synchronized ( resourceBundles )
+        {
+            ResourceBundle bundle = ( ResourceBundle ) resourceBundles.get( locale );
+            if ( bundle != null )
+            {
+                return bundle;
+            }
+        }
+
+        ResourceBundle parent = getResourceBundleInternal( getParentLocale( locale ) );
+        ResourceBundle bundle = loadResourceBundle( parent, locale );
+        synchronized ( resourceBundles )
+        {
+            resourceBundles.put( locale, bundle );
+        }
+
+        return bundle;
+    }
+
+
+    private ResourceBundle loadResourceBundle( final ResourceBundle parent, final Locale locale )
+    {
+        final String path = "_" + locale.toString();
+        final URL source = ( URL ) getResourceBundleEntries().get( path );
+        return new ConsolePropertyResourceBundle( parent, source );
+    }
+
+
+    private synchronized Map getResourceBundleEntries()
+    {
+        if ( this.resourceBundleEntries == null )
+        {
+            String file = ( String ) bundle.getHeaders().get( Constants.BUNDLE_LOCALIZATION );
+            if ( file == null )
+            {
+                file = Constants.BUNDLE_LOCALIZATION_DEFAULT_BASENAME;
+            }
+
+            // remove leading slash
+            if ( file.startsWith( "/" ) )
+            {
+                file = file.substring( 1 );
+            }
+
+            // split path and base name
+            int slash = file.lastIndexOf( '/' );
+            String fileName = file.substring( slash + 1 );
+            String path = ( slash <= 0 ) ? "/" : file.substring( 0, slash );
+
+            HashMap resourceBundleEntries = new HashMap();
+
+            Enumeration locales = bundle.findEntries( path, fileName + "*.properties", false );
+            while ( locales.hasMoreElements() )
+            {
+                URL entry = ( URL ) locales.nextElement();
+
+                // calculate the key
+                String entryPath = entry.getPath();
+                final int start = 1 + file.length(); // leading slash
+                final int end = entryPath.length() - 11; // .properties suffix
+                entryPath = entryPath.substring( start, end );
+
+                resourceBundleEntries.put( entryPath, entry );
+            }
+
+            this.resourceBundleEntries = resourceBundleEntries;
+        }
+
+        return this.resourceBundleEntries;
+    }
+
+
+    private Locale getParentLocale( Locale locale )
+    {
+        if ( locale.getVariant().length() != 0 )
+        {
+            return new Locale( locale.getLanguage(), locale.getCountry() );
+        }
+        else if ( locale.getCountry().length() != 0 )
+        {
+            return new Locale( locale.getLanguage() );
+        }
+        else if ( locale.getLanguage().equals( DEFAULT_LOCALE.getLanguage() ) )
+        {
+            return DEFAULT_LOCALE;
+        }
+
+        // no more parents
+        return null;
+    }
+
+}
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/ResourceBundleManager.java b/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/ResourceBundleManager.java
new file mode 100644
index 0000000..825e99b
--- /dev/null
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/internal/i18n/ResourceBundleManager.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.felix.webconsole.internal.i18n;
+
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.ResourceBundle;
+
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.BundleEvent;
+import org.osgi.framework.BundleListener;
+
+
+public class ResourceBundleManager implements BundleListener
+{
+
+    private final BundleContext bundleContext;
+
+    private final ResourceBundleCache consoleResourceBundleCache;
+
+    private final Map resourceBundleCaches;
+
+
+    public ResourceBundleManager( final BundleContext bundleContext )
+    {
+        this.bundleContext = bundleContext;
+        this.consoleResourceBundleCache = new ResourceBundleCache( bundleContext.getBundle() );
+        this.resourceBundleCaches = new HashMap();
+
+        bundleContext.addBundleListener( this );
+    }
+
+
+    public void dispose()
+    {
+        bundleContext.removeBundleListener( this );
+    }
+
+
+    public ResourceBundle getResourceBundle( final Bundle provider, final Locale locale )
+    {
+        // check whether we have to return the resource bundle for the
+        // Web Console itself in which case we directly return it
+        final ResourceBundle defaultResourceBundle = consoleResourceBundleCache.getResourceBundle( locale );
+        if ( provider == null || provider.equals( bundleContext.getBundle() ) )
+        {
+            return defaultResourceBundle;
+        }
+
+        ResourceBundleCache cache;
+        synchronized ( resourceBundleCaches )
+        {
+            Long key = new Long( provider.getBundleId() );
+            cache = ( ResourceBundleCache ) resourceBundleCaches.get( key );
+            if ( cache == null )
+            {
+                cache = new ResourceBundleCache( provider );
+                resourceBundleCaches.put( key, cache );
+            }
+        }
+
+        final ResourceBundle bundleResourceBundle = cache.getResourceBundle( locale );
+        return new CombinedResourceBundle( bundleResourceBundle, defaultResourceBundle );
+    }
+
+
+    // ---------- BundleListener
+
+    public void bundleChanged( BundleEvent event )
+    {
+        if ( event.getType() == BundleEvent.STOPPED )
+        {
+            Long key = new Long( event.getBundle().getBundleId() );
+            synchronized ( resourceBundleCaches )
+            {
+                resourceBundleCaches.remove( key );
+            }
+        }
+    }
+}
diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/internal/servlet/OsgiManager.java b/webconsole/src/main/java/org/apache/felix/webconsole/internal/servlet/OsgiManager.java
index 4d51a71..8f245bf 100644
--- a/webconsole/src/main/java/org/apache/felix/webconsole/internal/servlet/OsgiManager.java
+++ b/webconsole/src/main/java/org/apache/felix/webconsole/internal/servlet/OsgiManager.java
@@ -18,16 +18,42 @@
 
 
 import java.io.IOException;
-import java.util.*;
+import java.util.Collection;
+import java.util.Dictionary;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Hashtable;
+import java.util.Iterator;
+import java.util.Locale;
+import java.util.Map;
+import java.util.ResourceBundle;
+import java.util.Set;
 
-import javax.servlet.*;
+import javax.servlet.GenericServlet;
+import javax.servlet.Servlet;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-import org.apache.felix.webconsole.*;
-import org.apache.felix.webconsole.internal.*;
+import org.apache.felix.webconsole.AbstractWebConsolePlugin;
+import org.apache.felix.webconsole.Action;
+import org.apache.felix.webconsole.BrandingPlugin;
+import org.apache.felix.webconsole.VariableResolver;
+import org.apache.felix.webconsole.WebConsoleConstants;
+import org.apache.felix.webconsole.internal.Logger;
+import org.apache.felix.webconsole.internal.OsgiManagerPlugin;
+import org.apache.felix.webconsole.internal.Util;
+import org.apache.felix.webconsole.internal.WebConsolePluginAdapter;
 import org.apache.felix.webconsole.internal.core.BundlesServlet;
-import org.osgi.framework.*;
+import org.apache.felix.webconsole.internal.filter.FilteringResponseWrapper;
+import org.apache.felix.webconsole.internal.i18n.ResourceBundleManager;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceReference;
+import org.osgi.framework.ServiceRegistration;
 import org.osgi.service.http.HttpContext;
 import org.osgi.service.http.HttpService;
 import org.osgi.service.log.LogService;
@@ -35,7 +61,9 @@
 
 
 /**
- * The <code>OSGi Manager</code> TODO
+ * The <code>OSGi Manager</code> is the actual Web Console Servlet which
+ * is registered with the OSGi Http Service and which maintains registered
+ * console plugins.
  */
 public class OsgiManager extends GenericServlet
 {
@@ -133,13 +161,17 @@
 
     private ServiceRegistration configurationListener;
 
+    // map of plugins: indexed by the plugin label (String), values are
+    // AbstractWebConsolePlugin instances
     private Map plugins = new HashMap();
 
+    // map of labels to plugin titles: indexed by plugin label (String, values
+    // are plugin titles
     private Map labelMap = new HashMap();
 
     private Map operations = new HashMap();
 
-    private Servlet defaultRender;
+    private AbstractWebConsolePlugin defaultPlugin;
 
     private String defaultRenderName;
 
@@ -155,6 +187,7 @@
 
     private Set enabledPlugins;
 
+    private ResourceBundleManager resourceBundleManager;
 
     public OsgiManager( BundleContext bundleContext )
     {
@@ -201,7 +234,7 @@
             log.dispose();
         }
 
-        this.defaultRender = null;
+        this.defaultPlugin = null;
         this.bundleContext = null;
     }
 
@@ -237,8 +270,7 @@
                 }
                 if ( plugin instanceof AbstractWebConsolePlugin )
                 {
-                    AbstractWebConsolePlugin amp = ( AbstractWebConsolePlugin ) plugin;
-                    bindServlet( amp.getLabel(), amp );
+                    bindServlet( ( AbstractWebConsolePlugin ) plugin );
                 }
                 else
                 {
@@ -275,6 +307,9 @@
         pluginsTracker.open();
         brandingTracker = new BrandingServiceTracker(this);
         brandingTracker.open();
+
+        // the resource bundle manager
+        resourceBundleManager = new ResourceBundleManager( getBundleContext() );
     }
 
     public void service( ServletRequest req, ServletResponse res ) throws ServletException, IOException
@@ -310,7 +345,7 @@
         }
 
         label = label.substring( 1, slash );
-        Servlet plugin = ( Servlet ) plugins.get( label );
+        AbstractWebConsolePlugin plugin = ( AbstractWebConsolePlugin ) plugins.get( label );
         if ( plugin != null )
         {
             // the official request attributes
@@ -322,7 +357,10 @@
             req.setAttribute( ATTR_LABEL_MAP_OLD, labelMap );
             req.setAttribute( ATTR_APP_ROOT_OLD, request.getContextPath() + request.getServletPath() );
 
-            plugin.service( req, res );
+            // wrap the response for localization and template variable replacement
+            response = wrapResponse( request, response, plugin );
+
+            plugin.service( request, response );
         }
         else
         {
@@ -337,6 +375,13 @@
         // base class destroy not needed, since the GenericServlet.destroy
         // is an empty method
 
+        // dispose off the resource bundle manager
+        if ( resourceBundleManager != null )
+        {
+            resourceBundleManager.dispose();
+            resourceBundleManager = null;
+        }
+
         // stop listening for plugins
         if ( operationsTracker != null )
         {
@@ -428,6 +473,16 @@
         return bundleContext;
     }
 
+
+    private HttpServletResponse wrapResponse( final HttpServletRequest request, final HttpServletResponse response,
+        final AbstractWebConsolePlugin plugin )
+    {
+        final Locale locale = request.getLocale();
+        final ResourceBundle resourceBundle = resourceBundleManager.getResourceBundle( plugin.getBundle(), locale );
+        final VariableResolver variables = ( plugin instanceof VariableResolver ) ? ( VariableResolver ) plugin : null;
+        return new FilteringResponseWrapper( response, resourceBundle, variables );
+    }
+
     private static class HttpServiceTracker extends ServiceTracker
     {
 
@@ -521,25 +576,39 @@
                 {
                     // wrap the servlet if it is not an AbstractWebConsolePlugin
                     // but has a title in the service properties
-                    if ( !( operation instanceof AbstractWebConsolePlugin ) )
+                    final AbstractWebConsolePlugin plugin;
+                    if ( operation instanceof AbstractWebConsolePlugin )
                     {
+                        plugin = ( AbstractWebConsolePlugin ) operation;
+                    }
+                    else
+                    {
+
+                        // define the title from the PLUGIN_TITLE registration
+                        // property, the servlet name or the servlet "toString"
                         Object title = reference.getProperty( WebConsoleConstants.PLUGIN_TITLE );
-                        if ( title instanceof String )
+                        if ( !( title instanceof String ) )
                         {
-                            WebConsolePluginAdapter pluginAdapter = new WebConsolePluginAdapter( ( String ) label,
-                                ( String ) title, ( Servlet ) operation, reference );
+                            if ( operation instanceof GenericServlet )
+                            {
+                                title = ( ( GenericServlet ) operation ).getServletName();
+                            }
 
-                            // ensure the AbstractWebConsolePlugin is correctly setup
-                            Bundle pluginBundle = reference.getBundle();
-                            pluginAdapter.activate( pluginBundle.getBundleContext() );
-
-                            // now use this adapter as the operation
-                            operation = pluginAdapter;
+                            if ( !( title instanceof String ) )
+                            {
+                                title = operation.toString();
+                            }
                         }
+
+                        plugin = new WebConsolePluginAdapter( ( String ) label, ( String ) title,
+                            ( Servlet ) operation, reference );
+
+                        // ensure the AbstractWebConsolePlugin is correctly setup
+                        Bundle pluginBundle = reference.getBundle();
+                        plugin.activate( pluginBundle.getBundleContext() );
                     }
 
-                    // TODO: check reference properties !!
-                    osgiManager.bindServlet( ( String ) label, ( Servlet ) operation );
+                    osgiManager.bindServlet( plugin );
                 }
                 return operation;
             }
@@ -680,58 +749,53 @@
     }
 
 
-    protected void bindServlet( String label, Servlet servlet )
+    private void bindServlet( final AbstractWebConsolePlugin plugin )
     {
+        final String label = plugin.getLabel();
+        final String title = plugin.getTitle();
         try
         {
-            servlet.init( getServletConfig() );
-            plugins.put( label, servlet );
+            plugin.init( getServletConfig() );
+            plugins.put( label, plugin );
+            labelMap.put( label, title );
 
-            if ( servlet instanceof GenericServlet )
+            if ( this.defaultPlugin == null )
             {
-                String title = ( ( GenericServlet ) servlet ).getServletName();
-                if ( title != null )
-                {
-                    labelMap.put( label, title );
-                }
-            }
-
-            if ( this.defaultRender == null )
-            {
-                this.defaultRender = servlet;
+                this.defaultPlugin = plugin;
             }
             else if ( label.equals( this.defaultRenderName ) )
             {
-                this.defaultRender = servlet;
+                this.defaultPlugin = plugin;
             }
         }
         catch ( ServletException se )
         {
-            // TODO: log
+            log.log( LogService.LOG_WARNING, "Initialization of plugin '" + title + "' (" + label
+                + ") failed; not using this plugin", se );
         }
     }
 
 
-    protected void unbindServlet( String label )
+    private void unbindServlet( String label )
     {
-        Servlet servlet = ( Servlet ) plugins.remove( label );
-        if ( servlet != null )
+        AbstractWebConsolePlugin plugin = ( AbstractWebConsolePlugin ) plugins.remove( label );
+        if ( plugin != null )
         {
             labelMap.remove( label );
 
-            if ( this.defaultRender == servlet )
+            if ( this.defaultPlugin == plugin )
             {
                 if ( this.plugins.isEmpty() )
                 {
-                    this.defaultRender = null;
+                    this.defaultPlugin = null;
                 }
                 else
                 {
-                    this.defaultRender = ( Servlet ) plugins.values().iterator().next();
+                    this.defaultPlugin = ( AbstractWebConsolePlugin ) plugins.values().iterator().next();
                 }
             }
 
-            servlet.destroy();
+            plugin.destroy();
         }
     }
 
@@ -766,7 +830,7 @@
         defaultRenderName = getProperty( config, PROP_DEFAULT_RENDER, DEFAULT_PAGE );
         if ( defaultRenderName != null && plugins.get( defaultRenderName ) != null )
         {
-            defaultRender = ( Servlet ) plugins.get( defaultRenderName );
+            defaultPlugin = ( AbstractWebConsolePlugin ) plugins.get( defaultRenderName );
         }
 
         // get the web manager root path