/* * HelpController.java 20 juil. 07 * * Sweet Home 3D, Copyright (c) 2007 Emmanuel PUYBARET / eTeks <info@eteks.com> * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ package com.eteks.sweethome3d.viewcontroller; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.lang.ref.WeakReference; import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; import java.net.URLStreamHandler; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Set; import javax.swing.text.BadLocationException; import javax.swing.text.ChangedCharSetException; import javax.swing.text.MutableAttributeSet; import javax.swing.text.html.HTML; import javax.swing.text.html.HTML.Tag; import javax.swing.text.html.HTMLDocument; import javax.swing.text.html.HTMLEditorKit; import com.eteks.sweethome3d.model.UserPreferences; import com.eteks.sweethome3d.tools.ResourceURLContent; /** * A MVC controller for Sweet Home 3D help view. * @author Emmanuel Puybaret */ public class HelpController implements Controller { /** * The properties that may be edited by the view associated to this controller. */ public enum Property {HELP_PAGE, BROWSER_PAGE, PREVIOUS_PAGE_ENABLED, NEXT_PAGE_ENABLED, HIGHLIGHTED_TEXT} private static final String SEARCH_RESULT_PROTOCOL = "search"; private final UserPreferences preferences; private final ViewFactory viewFactory; private final PropertyChangeSupport propertyChangeSupport; private final List<URL> history; private int historyIndex; private HelpView helpView; private URL helpPage; private URL browserPage; private boolean previousPageEnabled; private boolean nextPageEnabled; private String highlightedText; public HelpController(UserPreferences preferences, ViewFactory viewFactory) { this.preferences = preferences; this.viewFactory = viewFactory; this.propertyChangeSupport = new PropertyChangeSupport(this); this.history = new ArrayList<URL>(); this.historyIndex = -1; showPage(getHelpIndexPageURL()); } /** * Returns the view associated with this controller. */ public HelpView getView() { if (this.helpView == null) { this.helpView = this.viewFactory.createHelpView(this.preferences, this); addLanguageListener(this.preferences); } return this.helpView; } /** * Displays the help view controlled by this controller. */ public void displayView() { getView().displayView(); } /** * Adds the property change <code>listener</code> in parameter to this controller. */ public void addPropertyChangeListener(Property property, PropertyChangeListener listener) { this.propertyChangeSupport.addPropertyChangeListener(property.name(), listener); } /** * Removes the property change <code>listener</code> in parameter from this controller. */ public void removePropertyChangeListener(Property property, PropertyChangeListener listener) { this.propertyChangeSupport.removePropertyChangeListener(property.name(), listener); } /** * Sets the current page. */ private void setHelpPage(URL helpPage) { if (helpPage != this.helpPage) { URL oldHelpPage = this.helpPage; this.helpPage = helpPage; this.propertyChangeSupport.firePropertyChange(Property.HELP_PAGE.name(), oldHelpPage, helpPage); } } /** * Returns the current page. */ public URL getHelpPage() { return this.helpPage; } /** * Sets the browser page. */ private void setBrowserPage(URL browserPage) { if (browserPage != this.browserPage) { URL oldBrowserPage = this.browserPage; this.browserPage = browserPage; this.propertyChangeSupport.firePropertyChange(Property.BROWSER_PAGE.name(), oldBrowserPage, browserPage); } } /** * Returns the browser page. */ public URL getBrowserPage() { return this.browserPage; } /** * Sets whether a previous page is available or not. */ private void setPreviousPageEnabled(boolean previousPageEnabled) { if (previousPageEnabled != this.previousPageEnabled) { this.previousPageEnabled = previousPageEnabled; this.propertyChangeSupport.firePropertyChange(Property.PREVIOUS_PAGE_ENABLED.name(), !previousPageEnabled, previousPageEnabled); } } /** * Returns whether a previous page is available or not. */ public boolean isPreviousPageEnabled() { return this.previousPageEnabled; } /** * Sets whether a next page is available or not. */ private void setNextPageEnabled(boolean nextPageEnabled) { if (nextPageEnabled != this.nextPageEnabled) { this.nextPageEnabled = nextPageEnabled; this.propertyChangeSupport.firePropertyChange(Property.NEXT_PAGE_ENABLED.name(), !nextPageEnabled, nextPageEnabled); } } /** * Returns whether a next page is available or not. */ public boolean isNextPageEnabled() { return this.nextPageEnabled; } /** * Sets the highlighted text. */ public void setHighlightedText(String highlightedText) { if (highlightedText != this.highlightedText && (highlightedText == null || !highlightedText.equals(this.highlightedText))) { String oldHighlightedText = this.highlightedText; this.highlightedText = highlightedText; this.propertyChangeSupport.firePropertyChange(Property.HIGHLIGHTED_TEXT.name(), oldHighlightedText, highlightedText); } } /** * Returns the highlighted text. */ public String getHighlightedText() { return getHelpPage() == null || SEARCH_RESULT_PROTOCOL.equals(getHelpPage().getProtocol()) ? null : this.highlightedText; } /** * Adds a property change listener to <code>preferences</code> to update * displayed page when language changes. */ private void addLanguageListener(UserPreferences preferences) { preferences.addPropertyChangeListener(UserPreferences.Property.LANGUAGE, new LanguageChangeListener(this)); } /** * Preferences property listener bound to this component with a weak reference to avoid * strong link between preferences and this component. */ private static class LanguageChangeListener implements PropertyChangeListener { private WeakReference<HelpController> helpController; public LanguageChangeListener(HelpController helpController) { this.helpController = new WeakReference<HelpController>(helpController); } public void propertyChange(PropertyChangeEvent ev) { // If help controller was garbage collected, remove this listener from preferences HelpController helpController = this.helpController.get(); if (helpController == null) { ((UserPreferences)ev.getSource()).removePropertyChangeListener( UserPreferences.Property.LANGUAGE, this); } else { // Updates home page from current default locale helpController.history.clear(); helpController.historyIndex = -1; helpController.showPage(helpController.getHelpIndexPageURL()); } } } /** * Controls the display of previous page. */ public void showPrevious() { setHelpPage(this.history.get(--this.historyIndex)); setPreviousPageEnabled(this.historyIndex > 0); setNextPageEnabled(true); } /** * Controls the display of next page. */ public void showNext() { setHelpPage(this.history.get(++this.historyIndex)); setPreviousPageEnabled(true); setNextPageEnabled(this.historyIndex < this.history.size() - 1); } /** * Controls the display of the given <code>page</code>. */ public void showPage(URL page) { if (isBrowserPage(page)) { setBrowserPage(page); } else if (this.historyIndex == -1 || !this.history.get(this.historyIndex).equals(page)) { setHelpPage(page); for (int i = this.history.size() - 1; i > this.historyIndex; i--) { this.history.remove(i); } this.history.add(page); setPreviousPageEnabled(++this.historyIndex > 0); setNextPageEnabled(false); } } /** * Returns <code>true</code> if the given <code>page</code> should be displayed * by the system browser rather than by the help view. * By default, it returns <code>true</code> if the <code>page</code> protocol is http or https. */ protected boolean isBrowserPage(URL page) { String protocol = page.getProtocol(); return protocol.equals("http") || protocol.equals("https"); } /** * Returns the URL of the help index page. */ private URL getHelpIndexPageURL() { String helpIndex = this.preferences.getLocalizedString(HelpController.class, "helpIndex"); try { // Try first to interpret contentFile as an absolute URL return new URL(helpIndex); } catch (MalformedURLException ex) { String classPackage = HelpController.class.getName(); classPackage = classPackage.substring(0, classPackage.lastIndexOf(".")).replace('.', '/'); String helpIndexWithoutLeadingSlash = helpIndex.startsWith("/") ? helpIndex.substring(1) : classPackage + '/' + helpIndex; for (ClassLoader classLoader : this.preferences.getResourceClassLoaders()) { try { return new ResourceURLContent(classLoader, helpIndexWithoutLeadingSlash).getURL(); } catch (IllegalArgumentException ex2) { // Try next class loader } } try { // Build URL of index page with ResourceURLContent because of Java bug #6746185 return new ResourceURLContent(HelpController.class, helpIndex).getURL(); } catch (IllegalArgumentException ex2) { ex2.printStackTrace(); // Return English help by default return new ResourceURLContent(HelpController.class, "resources/help/en/index.html").getURL(); } } } /** * Searches <code>searchedText</code> in help documents and displays * the result. */ public void search(String searchedText) { URL helpIndex = getHelpIndexPageURL(); String [] searchedWords = getLowerCaseSearchedWords(searchedText); List<HelpDocument> helpDocuments = searchInHelpDocuments(helpIndex, searchedWords); URL applicationIconUrl = null; try { applicationIconUrl = new ResourceURLContent(HelpController.class, "resources/help/images/applicationIcon32.png").getURL(); } catch (Exception ex) { // Ignore icon } // Build dynamically the search result page final StringBuilder htmlText = new StringBuilder( "<html><head><meta http-equiv='content-type' content='text/html;charset=UTF-8'><link href='" + new ResourceURLContent(HelpController.class, "resources/help/help.css").getURL() + "' rel='stylesheet'></head><body bgcolor='#ffffff'>\n" + "<div id='banner'><div id='helpheader'>" + " <a class='bread' href='" + helpIndex + "'> " + this.preferences.getLocalizedString(HelpController.class, "helpTitle") + "</a>" + "</div></div>" + "<div id='mainbox' align='left'>" + " <table width='100%' border='0' cellspacing='0' cellpadding='0'>" + " <tr valign='bottom' height='32'>" + " <td width='3' height='32'> </td>" + (applicationIconUrl != null ? "<td width='32' height='32'><img src='" + applicationIconUrl + "' height='32' width='32'></td>" : "") + " <td width='8' height='32'>  </td>" + " <td valign='bottom' height='32'><font id='topic'>" + this.preferences.getLocalizedString(HelpController.class, "searchResult") + "</font></td>" + " </tr>" + " <tr height='10'><td colspan='4' height='10'> </td></tr>" + " </table>" + " <table width='100%' border='0' cellspacing='0' cellpadding='3'>"); if (helpDocuments.size() == 0) { String searchNotFound = this.preferences.getLocalizedString(HelpController.class, "searchNotFound", searchedText); htmlText.append("<tr><td><p>" + searchNotFound + "</td></tr>"); } else { String searchFound = this.preferences.getLocalizedString(HelpController.class, "searchFound", searchedText); htmlText.append("<tr><td colspan='2'><p>" + searchFound + "</td></tr>"); URL searchRelevanceImage = new ResourceURLContent(HelpController.class, "resources/searchRelevance.gif").getURL(); for (HelpDocument helpDocument : helpDocuments) { // Add hyperlink to help document found htmlText.append("<tr><td valign='middle' nowrap><a href='" + helpDocument.getBase() + "'>" + helpDocument.getTitle() + "</a></td><td valign='middle'>"); // Add relevance image for (int i = 0; i < helpDocument.getRelevance() && i < 50; i++) { htmlText.append("<img src='" + searchRelevanceImage + "' width='4' height='12'>"); } htmlText.append("</td></tr>"); } } htmlText.append("</table></div></body></html>"); try { // Show built HTML text as a page read from an URL showPage(new URL(null, SEARCH_RESULT_PROTOCOL + "://" + htmlText.hashCode(), new URLStreamHandler() { @Override protected URLConnection openConnection(URL url) throws IOException { return new URLConnection(url) { @Override public void connect() throws IOException { // Don't need to connect } @Override public InputStream getInputStream() throws IOException { return new ByteArrayInputStream( htmlText.toString().getBytes("UTF-8")); } }; } })); } catch (MalformedURLException ex) { // Can't happen } } /** * Returns the searched words in the given text. */ private String [] getLowerCaseSearchedWords(String searchedText) { String [] searchedWords = searchedText.split("\\s"); for (int i = 0; i < searchedWords.length; i++) { searchedWords [i] = searchedWords [i].toLowerCase().trim(); } return searchedWords; } /** * Searches <code>searchedWords</code> in help documents and returns * the list of matching documents sorted from the most relevant to the least relevant. * This method uses some Swing classes for their HTML parsing capabilities * and not to create components. */ private List<HelpDocument> searchInHelpDocuments(URL helpIndex, String [] searchedWords) { List<URL> parsedDocuments = new ArrayList<URL>(); parsedDocuments.add(helpIndex); List<HelpDocument> helpDocuments = new ArrayList<HelpDocument>(); // Parse all the URLs added to parsedDocuments at each loop for (int i = 0; i < parsedDocuments.size(); i++) { try { // Parse a HTML document URL helpDocumentUrl = parsedDocuments.get(i); HelpDocument helpDocument = new HelpDocument(helpDocumentUrl, searchedWords); helpDocument.parse(); // If searched text was found add it to returned documents list if (helpDocument.getRelevance() > 0) { helpDocuments.add(helpDocument); } // Check if the HTML file contains new URLs to parse for (URL url : helpDocument.getReferencedDocuments()) { String lowerCaseFile = url.getFile().toLowerCase(); if (lowerCaseFile.endsWith(".html") && !parsedDocuments.contains(url)) { parsedDocuments.add(url); } } } catch (IOException ex) { // Ignore unknown documents (their URLs should be checked outside of Sweet Home 3D) } } // Sort by relevance Collections.sort(helpDocuments, new Comparator<HelpDocument>() { public int compare(HelpDocument document1, HelpDocument document2) { return document2.getRelevance() - document1.getRelevance(); } }); return helpDocuments; } /** * A help HTML document parsed with <code>HTMLEditorKit</code>. */ private class HelpDocument extends HTMLDocument { // Documents set referenced in this file private Set<URL> referencedDocuments = new HashSet<URL>(); private String [] searchedWords; private int relevance; private String title = ""; public HelpDocument(URL helpDocument, String [] searchedWords) { this.searchedWords = searchedWords; // Store HTML file base setBase(helpDocument); } /** * Parses this document. */ public void parse() throws IOException { HTMLEditorKit html = new HTMLEditorKit(); Reader urlReader = null; try { urlReader = new InputStreamReader(getBase().openStream(), "ISO-8859-1"); // Parse HTML file first without ignoring charset directive putProperty("IgnoreCharsetDirective", Boolean.FALSE); try { html.read(urlReader, this, 0); } catch (ChangedCharSetException ex) { // Retrieve document real encoding String mimeType = ex.getCharSetSpec(); String encoding = mimeType.substring(mimeType.indexOf("=") + 1).trim(); // Restart reading document with its real encoding urlReader.close(); urlReader = new InputStreamReader(getBase().openStream(), encoding); putProperty("IgnoreCharsetDirective", Boolean.TRUE); html.read(urlReader, this, 0); } } catch (BadLocationException ex) { } finally { if (urlReader != null) { try { urlReader.close(); } catch (IOException ex) { } } } } public Set<URL> getReferencedDocuments() { return this.referencedDocuments; } public int getRelevance() { return this.relevance; } public String getTitle() { return this.title; } private void addReferencedDocument(String referencedDocument) { try { URL url = new URL(getBase(), referencedDocument); if (!isBrowserPage(url)) { URL urlWithNoAnchor = new URL( url.getProtocol(), url.getHost(), url.getPort(), url.getFile()); this.referencedDocuments.add(urlWithNoAnchor); } } catch (MalformedURLException e) { // Ignore malformed URLs (they should be checked outside of Sweet Home 3D) } } @Override public HTMLEditorKit.ParserCallback getReader(int pos) { // Change default callback reader return new HelpReader(); } // Reader that tracks all <a href=...> tags in current HTML document private class HelpReader extends HTMLEditorKit.ParserCallback { private boolean inTitle; @Override public void handleStartTag(HTML.Tag tag, MutableAttributeSet att, int pos) { if (tag.equals(HTML.Tag.A)) { // <a href=...> tag String attribute = (String)att.getAttribute(HTML.Attribute.HREF); if (attribute != null) { addReferencedDocument(attribute); } } else if (tag.equals(HTML.Tag.TITLE)) { this.inTitle = true; } } @Override public void handleEndTag(Tag tag, int pos) { if (tag.equals(HTML.Tag.TITLE)) { this.inTitle = false; } } @Override public void handleSimpleTag(Tag tag, MutableAttributeSet att, int pos) { if (tag.equals(HTML.Tag.META)) { String nameAttribute = (String)att.getAttribute(HTML.Attribute.NAME); String contentAttribute = (String)att.getAttribute(HTML.Attribute.CONTENT); if ("keywords".equalsIgnoreCase(nameAttribute) && contentAttribute != null) { searchWords(contentAttribute); } } } @Override public void handleText(char [] data, int pos) { String text = new String(data); if (this.inTitle) { title += text; } searchWords(text); } private void searchWords(String text) { String lowerCaseText = text.toLowerCase(); for (String searchedWord : searchedWords) { for (int index = 0; index < lowerCaseText.length(); index += searchedWord.length() + 1) { index = lowerCaseText.indexOf(searchedWord, index); if (index == -1) { break; } else { relevance++; // Give more relevance to searchedWord when it's found in title if (this.inTitle) { relevance++; } } } } } } } }