001// Copyright (C) 1998-2001 by Jason Hunter <jhunter_AT_acm_DOT_org>.
002// All rights reserved.  Use of this class is limited.
003// Please see the LICENSE for more information.
004
005package com.oreilly.servlet;
006
007import java.io.*;
008import java.util.*;
009
010import com.oreilly.servlet.LocaleToCharsetMap;
011
012/** 
013 * A class to aid in servlet internationalization.  It determines, from a 
014 * client request, the best charset, locale, and resource bundle to use 
015 * with the response.
016 * <p>
017 * LocaleNegotiator works by scanning through the client's language 
018 * preferences (sent by browsers in the <tt>Accept-Language</tt> header) 
019 * looking for any 
020 * language for which there exists is a corresponding resource bundle.
021 * When it finds a correspondence, it uses the LocaleToCharsetMap class
022 * to determine the charset.  If there's any problem, it tries to fall 
023 * back to US English.  The logic currently ignores the client's charset 
024 * preferences (sent in the <tt>Accept-Charset</tt> header).
025 * <p>
026 * It can be used like this:
027 * <blockquote><pre>
028 * String bundleName = "BundleName";
029 * String acceptLanguage = req.getHeader("Accept-Language");
030 * String acceptCharset = req.getHeader("Accept-Charset");
031 * &nbsp;
032 * LocaleNegotiator negotiator =
033 *   new LocaleNegotiator(bundleName, acceptLanguage, acceptCharset);
034 * &nbsp;
035 * Locale locale = negotiator.getLocale();
036 * String charset = negotiator.getCharset();
037 * ResourceBundle bundle = negotiator.getBundle();  // may be null
038 * &nbsp;
039 * res.setContentType("text/plain; charset=" + charset);
040 * res.setHeader("Content-Language", locale.getLanguage());
041 * res.setHeader("Vary", "Accept-Language");
042 * &nbsp;
043 * PrintWriter out = res.getWriter();
044 * &nbsp;
045 * out.println(bundle.getString("resource"));
046 * </pre></blockquote>
047 *
048 * @see com.oreilly.servlet.LocaleToCharsetMap
049 *
050 * @author <b>Jason Hunter</b>, Copyright &#169; 1998
051 * @version 1.0, 98/09/18
052 */
053public class LocaleNegotiator {
054
055  private ResourceBundle chosenBundle; 
056  private Locale chosenLocale; 
057  private String chosenCharset; 
058
059  /**
060   * Constructs a new LocaleNegotiator for the given bundle name, language
061   * list, and charset list.
062   *
063   * @param bundleName the resource bundle name
064   * @param languages the Accept-Language header
065   * @param charsets the Accept-Charset header
066   */
067  public LocaleNegotiator(String bundleName,
068                          String languages,
069                          String charsets) {
070
071    // Specify default values:
072    //   English language, ISO-8859-1 (Latin-1) charset, English bundle
073    Locale defaultLocale = new Locale("en", "US");
074    String defaultCharset = "ISO-8859-1";
075    ResourceBundle defaultBundle = null;
076    try {
077      defaultBundle = ResourceBundle.getBundle(bundleName, defaultLocale);
078    }
079    catch (MissingResourceException e) {
080      // No default bundle was found.  Flying without a net.
081    }
082
083    // If the client didn't specify acceptable languages, we can keep
084    // the defaults.
085    if (languages == null) {
086      chosenLocale = defaultLocale;
087      chosenCharset = defaultCharset;
088      chosenBundle = defaultBundle;
089      return;  // quick exit
090    }
091
092    // Use a tokenizer to separate acceptable languages
093    StringTokenizer tokenizer = new StringTokenizer(languages, ",");
094
095    while (tokenizer.hasMoreTokens()) {
096      // Get the next acceptable language.
097      // (The language can look something like "en; qvalue=0.91")
098      String lang = tokenizer.nextToken();
099
100      // Get the locale for that language
101      Locale loc = getLocaleForLanguage(lang);
102
103      // Get the bundle for this locale.  Don't let the search fallback 
104      // to match other languages!
105      ResourceBundle bundle = getBundleNoFallback(bundleName, loc);
106
107      // The returned bundle is null if there's no match.  In that case
108      // we can't use this language since the servlet can't speak it.
109      if (bundle == null) continue;  // on to the next language
110
111      // Find a charset we can use to display that locale's language.
112      String charset = getCharsetForLocale(loc, charsets);
113
114      // The returned charset is null if there's no match.  In that case
115      // we can't use this language since the servlet can't encode it.
116      if (charset == null) continue;  // on to the next language
117
118      // If we get here, there are no problems with this language.
119      chosenLocale = loc;
120      chosenBundle = bundle;
121      chosenCharset = charset;
122      return;  // we're done
123    }
124
125    // No matches, so we let the defaults stand
126    chosenLocale = defaultLocale;
127    chosenCharset = defaultCharset;
128    chosenBundle = defaultBundle;
129  }
130
131  /**
132   * Gets the chosen bundle.
133   *
134   * @return the chosen bundle
135   */
136  public ResourceBundle getBundle() {
137    return chosenBundle;
138  }
139
140  /**
141   * Gets the chosen locale.
142   *
143   * @return the chosen locale
144   */
145  public Locale getLocale() {
146    return chosenLocale;
147  }
148
149  /**
150   * Gets the chosen charset.
151   *
152   * @return the chosen charset
153   */
154  public String getCharset() {
155    return chosenCharset;
156  }
157
158  /*
159   * Gets a Locale object for a given language string
160   */
161  private Locale getLocaleForLanguage(String lang) {
162    Locale loc;
163    int semi, dash;
164
165    // Cut off any qvalue that might come after a semi-colon
166    if ((semi = lang.indexOf(';')) != -1) {
167      lang = lang.substring(0, semi);
168    }
169
170    // Trim any whitespace
171    lang = lang.trim();
172
173    // Create a Locale from the language.  A dash may separate the
174    // language from the country.
175    if ((dash = lang.indexOf('-')) == -1) {
176      loc = new Locale(lang, "");  // No dash, no country
177    }
178    else {
179      loc = new Locale(lang.substring(0, dash), lang.substring(dash+1));
180    }
181
182    return loc;
183  }
184
185  /*
186   * Gets a ResourceBundle object for the given bundle name and locale,
187   * or null if the bundle can't be found.  The resource bundle must match
188   * the locale exactly.  Fallback matches are not permitted.
189   */
190  private ResourceBundle getBundleNoFallback(String bundleName, Locale loc) {
191
192    // First get the fallback bundle -- the bundle that will be selected 
193    // if getBundle() can't find a direct match.  This bundle can be
194    // compared to the bundles returned by later calls to getBundle() in
195    // order to detect when getBundle() finds a direct match.
196    ResourceBundle fallback = null;
197    try {
198      fallback =
199        ResourceBundle.getBundle(bundleName, new Locale("bogus", ""));
200    }
201    catch (MissingResourceException e) {
202      // No fallback bundle was found.
203    }
204
205    try {
206      // Get the bundle for the specified locale
207      ResourceBundle bundle = ResourceBundle.getBundle(bundleName, loc);
208
209      // Is the bundle different than our fallback bundle?
210      if (bundle != fallback) {
211        // We have a real match!
212        return bundle;
213      }
214      // So the bundle is the same as our fallback bundle.
215      // We can still have a match, but only if our locale's language 
216      // matches the default locale's language.
217      else if (bundle == fallback &&
218            loc.getLanguage().equals(Locale.getDefault().getLanguage())) {
219        // Another way to match
220        return bundle;
221      }
222      else {
223        // No match, keep looking
224      }
225    }
226    catch (MissingResourceException e) {
227      // No bundle available for this locale
228    }
229
230    return null;  // no match
231  }
232
233  /**
234   * Gets the best charset for a given locale, selecting from a charset list.
235   * Currently ignores the charset list.  Subclasses can override this 
236   * method to take the list into account.
237   *
238   * @param loc the locale
239   * @param charsets a comma-separated charset list
240   * @return the best charset for the given locale from the given list
241   */
242  protected String getCharsetForLocale(Locale loc, String charsets) {
243    // Note: This method ignores the client-specified charsets
244    return LocaleToCharsetMap.getCharset(loc);
245  }
246}