/*
GNU GENERAL PUBLIC LICENSE
Copyright (C) 2006 The Lobo Project
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
verion 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 library; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Contact info: lobochief@users.sourceforge.net
*/
/*
* Created on May 14, 2005
*/
package org.lobobrowser.primary.clientlets.html;
import java.awt.event.ActionEvent;
import java.io.InputStream;
import java.net.URL;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.SwingUtilities;
import org.eclipse.jdt.annotation.NonNull;
import org.lobobrowser.clientlet.Clientlet;
import org.lobobrowser.clientlet.ClientletContext;
import org.lobobrowser.clientlet.ClientletException;
import org.lobobrowser.clientlet.ClientletResponse;
import org.lobobrowser.html.HtmlRendererContext;
import org.lobobrowser.html.domimpl.DocumentNotificationListener;
import org.lobobrowser.html.domimpl.HTMLDocumentImpl;
import org.lobobrowser.html.domimpl.NodeImpl;
import org.lobobrowser.html.gui.HtmlPanel;
import org.lobobrowser.html.parser.DocumentBuilderImpl;
import org.lobobrowser.html.parser.InputSourceImpl;
import org.lobobrowser.ua.NavigatorFrame;
import org.lobobrowser.ua.RequestType;
import org.lobobrowser.util.Strings;
import org.lobobrowser.util.Urls;
import org.lobobrowser.util.io.RecordedInputStream;
import org.w3c.dom.Element;
import org.w3c.dom.html.HTMLElement;
/**
* @author J. H. S.
*/
public final class HtmlClientlet implements Clientlet {
private static final Logger logger = Logger.getLogger(HtmlClientlet.class.getName());
private static final Set<String> NON_VISIBLE_ELEMENTS = new HashSet<>();
// Maximum buffer size required to determine if a reload due
// to Http-Equiv is necessary.
private static final int MAX_IS_BUFFER_SIZE = 1024 * 100;
public HtmlClientlet() {
super();
}
/*
* (non-Javadoc)
*
* @see org.xamjwg.clientlet.Clientlet#parse(org.xamjwg.dom.XDocument)
*/
public void process(final ClientletContext cc) throws ClientletException {
this.processImpl(cc, null, null);
}
private void processImpl(final ClientletContext cc, final Map<String, String> httpEquivData, RecordedInputStream rin)
throws ClientletException {
// This method may be executed twice, depending on http-equiv meta elements.
try {
final ClientletResponse response = cc.getResponse();
final boolean charsetProvided = response.isCharsetProvided();
final String contentLanguage = response.getHeader("Content-Language");
Set<Locale> locales = contentLanguage == null ? null : HtmlClientlet.extractLocales(contentLanguage);
RefreshInfo refresh = null;
final Iterator<String> hi = response.getHeaderNames();
// TODO: What is the behavior if you have
// a Refresh header and also a Refresh HTTP-EQUIV?
while (hi.hasNext()) {
final String headerName = hi.next();
final String[] headerValues = response.getHeaders(headerName);
if ((headerValues != null) && (headerValues.length > 0)) {
if ("refresh".equalsIgnoreCase(headerName)) {
refresh = HtmlClientlet.extractRefresh(headerValues[headerValues.length - 1]);
}
}
}
String httpEquivCharset = null;
if (httpEquivData != null) {
final Iterator<Map.Entry<String, String>> i = httpEquivData.entrySet().iterator();
while (i.hasNext()) {
final Map.Entry<String, String> entry = i.next();
final String httpEquiv = entry.getKey();
final String content = entry.getValue();
if (content != null) {
if ("content-type".equalsIgnoreCase(httpEquiv)) {
httpEquivCharset = HtmlClientlet.extractCharset(response.getResponseURL(), content);
} else if ("refresh".equalsIgnoreCase(httpEquiv)) {
refresh = HtmlClientlet.extractRefresh(content);
} else if ("content-language".equalsIgnoreCase(httpEquiv)) {
locales = HtmlClientlet.extractLocales(content);
}
}
}
}
final HtmlRendererContextImpl rcontext = HtmlRendererContextImpl.getHtmlRendererContext(cc.getNavigatorFrame());
final DocumentBuilderImpl builder = new DocumentBuilderImpl(rcontext.getUserAgentContext(), rcontext);
if (rin == null) {
final InputStream in = response.getInputStream();
rin = in instanceof RecordedInputStream ? (RecordedInputStream) in : new RecordedInputStream(in, MAX_IS_BUFFER_SIZE);
rin.mark(Short.MAX_VALUE);
} else {
rin.reset();
}
final URL responseURL = response.getResponseURL();
final String uri = responseURL.toExternalForm();
String charset;
if (!charsetProvided) {
charset = httpEquivCharset;
} else {
// See bug # 2051468. A charset provided
// in headers takes precendence.
charset = response.getCharset();
}
if (charset == null) {
// charset = "ISO-8859-1";
charset = "utf-8";
}
if (logger.isLoggable(Level.INFO)) {
logger.info("process(): charset=" + charset + " for URI=[" + uri + "]");
}
final InputSourceImpl is = new InputSourceImpl(rin, uri, charset);
final HTMLDocumentImpl document = (HTMLDocumentImpl) builder.createDocument(is, response.getContentType());
document.setLocales(locales);
final String referrer = cc.getRequest().getReferrer();
document.setReferrer(referrer == null ? "" : referrer);
final HtmlPanel panel = rcontext.getHtmlPanel();
// Create a listener that will switch to rendering when appropriate.
final HtmlContent content = new HtmlContent(document, panel, rin, charset);
final LocalDocumentNotificationListener listener = new LocalDocumentNotificationListener(document, panel, rcontext, cc, content,
httpEquivData == null);
document.addDocumentNotificationListener(listener);
// Set resulting content before parsing
// to enable incremental rendering.
// The load() call starts parsing.
try {
document.load(false);
} catch (final HttpEquivRetryException retry) {
if (logger.isLoggable(Level.INFO)) {
logger.info("processImpl(): Resetting due to META http-equiv: " + uri);
}
// This is a recursive call, but it doesn't go further
// than one level deep.
this.processImpl(cc, retry.getHttpEquivData(), rin);
return;
}
// We're done parsing, but let's make sure
// the listener actually renderered the document.
listener.ensureSwitchedToRendering();
// Scroll to see anchor.
/* This is now disabled, because we now layout lazily. There is no point in scrolling until the layout is done.
final String ref = responseURL.getRef();
if ((ref != null) && (ref.length() != 0)) {
panel.scrollToElement(ref);
}*/
if (refresh != null) {
final String destUri = refresh.destinationUrl;
final java.net.URL currentURL = response.getResponseURL();
@NonNull URL destURL;
if (destUri == null) {
destURL = currentURL;
} else {
destURL = Urls.createURL(currentURL, destUri);
}
final java.awt.event.ActionListener action = new java.awt.event.ActionListener() {
public void actionPerformed(final ActionEvent e) {
final NavigatorFrame frame = cc.getNavigatorFrame();
if (frame.getComponentContent() == content) {
// Navigate only if the original document is there.
// TODO: Address bar shouldn't change if it's being edited.
// TODO: A nagivation action should cancel this altogether.
frame.navigate(destURL, RequestType.PROGRAMMATIC);
}
}
};
int waitMillis = refresh.waitSeconds * 1000;
if (waitMillis <= 0) {
waitMillis = 1;
}
final javax.swing.Timer timer = new javax.swing.Timer(waitMillis, action);
timer.setRepeats(false);
timer.start();
}
} catch (final Exception err) {
throw new ClientletException(err);
}
}
private static String extractCharset(final java.net.URL responseURL, final String contentType) {
final StringTokenizer tok = new StringTokenizer(contentType, ";");
if (tok.hasMoreTokens()) {
tok.nextToken();
while (tok.hasMoreTokens()) {
final String assignment = tok.nextToken().trim();
final int eqIdx = assignment.indexOf('=');
if (eqIdx != -1) {
final String varName = assignment.substring(0, eqIdx).trim();
if ("charset".equalsIgnoreCase(varName)) {
final String varValue = assignment.substring(eqIdx + 1);
return Strings.unquote(varValue.trim());
}
}
}
}
return null;
}
private static Set<Locale> extractLocales(final String contentLanguage) {
final Set<Locale> locales = new HashSet<>(3);
final StringTokenizer tok = new StringTokenizer(contentLanguage, ",");
while (tok.hasMoreTokens()) {
final String lang = tok.nextToken().trim();
locales.add(new Locale(lang));
}
return locales;
}
private static String getDefaultCharset(final URL url) {
if (Urls.isLocalFile(url)) {
final String charset = System.getProperty("file.encoding");
return charset == null ? "ISO-8859-1" : charset;
} else {
return "ISO-8859-1";
}
}
private final static RefreshInfo extractRefresh(final String refresh) {
String delayText = null;
String urlText = null;
final StringTokenizer tok = new StringTokenizer(refresh, ";");
if (tok.hasMoreTokens()) {
delayText = tok.nextToken().trim();
while (tok.hasMoreTokens()) {
final String assignment = tok.nextToken().trim();
final int eqIdx = assignment.indexOf('=');
if (eqIdx != -1) {
final String varName = assignment.substring(0, eqIdx).trim();
if ("url".equalsIgnoreCase(varName)) {
final String varValue = assignment.substring(eqIdx + 1);
urlText = Strings.unquote(varValue.trim());
}
} else {
urlText = Strings.unquote(assignment);
}
}
}
int delay;
try {
delay = Integer.parseInt(delayText);
} catch (final NumberFormatException nfe) {
logger.warning("extractRefresh(): Bad META refresh delay: " + delayText + ".");
delay = 0;
}
return new RefreshInfo(delay, urlText);
}
private static class LocalDocumentNotificationListener implements DocumentNotificationListener {
private static final int MAX_WAIT = 7000;
private final HTMLDocumentImpl document;
private final HtmlPanel htmlPanel;
private final long startTimestamp;
private final HtmlRendererContext rcontext;
private final ClientletContext ccontext;
private final HtmlContent content;
private final boolean detectHttpEquiv;
private boolean hasVisibleElements = false;
private boolean hasSwitchedToRendering = false;
private Collection<HTMLElement> httpEquivElements;
public LocalDocumentNotificationListener(final HTMLDocumentImpl doc, final HtmlPanel panel, final HtmlRendererContext rcontext,
final ClientletContext cc,
final HtmlContent content, final boolean detectHttpEquiv) {
this.document = doc;
this.startTimestamp = System.currentTimeMillis();
this.htmlPanel = panel;
this.rcontext = rcontext;
this.ccontext = cc;
this.content = content;
this.detectHttpEquiv = detectHttpEquiv;
}
public void allInvalidated() {
}
public void externalScriptLoading(final NodeImpl node) {
// We can expect this to occur only in the parser thread.
if (this.hasVisibleElements) {
this.ensureSwitchedToRendering();
}
}
public void invalidated(final NodeImpl node) {
}
public void lookInvalidated(final NodeImpl node) {
}
private void addHttpEquivElement(final HTMLElement element) {
Collection<HTMLElement> httpEquivElements = this.httpEquivElements;
if (httpEquivElements == null) {
httpEquivElements = new LinkedList<>();
this.httpEquivElements = httpEquivElements;
}
httpEquivElements.add(element);
}
public void nodeLoaded(final NodeImpl node) {
// We can expect this to occur only in the parser thread.
if (this.detectHttpEquiv) {
if (node instanceof HTMLElement) {
final HTMLElement element = (HTMLElement) node;
final String tagName = element.getTagName();
if ("meta".equalsIgnoreCase(tagName)) {
final String httpEquiv = element.getAttribute("http-equiv");
if (httpEquiv != null) {
this.addHttpEquivElement(element);
}
}
if ("head".equalsIgnoreCase(tagName) || "script".equalsIgnoreCase(tagName) || "html".equalsIgnoreCase(tagName)) {
// Note: SCRIPT is checked as an optimization. We do not want
// scripts to be processed twice. HTML is checked because
// sometimes sites don't put http-equiv in HEAD, e.g.
// http://baidu.com.
final Map<String, String> httpEquiv = this.getHttpEquivData();
if ((httpEquiv != null) && (httpEquiv.size() > 0)) {
throw new HttpEquivRetryException(httpEquiv);
}
}
}
}
if (!this.hasVisibleElements) {
if (LocalDocumentNotificationListener.mayBeVisibleElement(node)) {
this.hasVisibleElements = true;
}
}
if (this.hasVisibleElements && ((System.currentTimeMillis() - this.startTimestamp) > MAX_WAIT)) {
this.ensureSwitchedToRendering();
}
}
public void positionInvalidated(final NodeImpl node) {
}
public void sizeInvalidated(final NodeImpl node) {
}
public void structureInvalidated(final NodeImpl node) {
}
private final static boolean mayBeVisibleElement(final NodeImpl node) {
if (node instanceof HTMLElement) {
final HTMLElement element = (HTMLElement) node;
final boolean visible = !NON_VISIBLE_ELEMENTS.contains(element.getTagName().toLowerCase());
if (visible && logger.isLoggable(Level.INFO)) {
logger.info("mayBeVisibleElement(): Found possibly visible element: " + element.getTagName());
}
return visible;
} else {
return false;
}
}
public void ensureSwitchedToRendering() {
synchronized (this) {
if (this.hasSwitchedToRendering) {
return;
}
this.hasSwitchedToRendering = true;
}
final HTMLDocumentImpl document = this.document;
document.removeDocumentNotificationListener(this);
SwingUtilities.invokeLater(() -> {
// Should have nicer effect (less flicker) in GUI thread.
htmlPanel.setDocument(document, rcontext);
ccontext.setResultingContent(content);
});
}
private Map<String, String> getHttpEquivData() {
final Collection<HTMLElement> httpEquivElements = this.httpEquivElements;
if (httpEquivElements == null) {
return null;
}
final Map<String, String> httpEquivData = new HashMap<>(0);
for (final Element element : httpEquivElements) {
final String httpEquiv = element.getAttribute("http-equiv");
if (httpEquiv != null) {
final String content = element.getAttribute("content");
httpEquivData.put(httpEquiv, content);
}
}
return httpEquivData;
}
}
private static class RefreshInfo {
public final int waitSeconds;
public final String destinationUrl;
public RefreshInfo(final int waitSeconds, final String destinationUrl) {
super();
this.waitSeconds = waitSeconds;
this.destinationUrl = destinationUrl;
}
}
private static class HttpEquivRetryException extends RuntimeException {
private static final long serialVersionUID = 3943944816518046414L;
private final Map<String, String> httpEquivData;
public HttpEquivRetryException(final Map<String, String> httpEquiv) {
super();
this.httpEquivData = httpEquiv;
}
public Map<String, String> getHttpEquivData() {
return httpEquivData;
}
}
}