blob: dc66e0f482de20108540a21a517ab8e1e539000c [file] [log] [blame]
// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed 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 com.google.enterprise.adaptor.sharepoint;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Multimap;
import com.google.common.io.CountingOutputStream;
import com.google.enterprise.adaptor.DocId;
import com.google.enterprise.adaptor.DocIdEncoder;
import com.google.enterprise.adaptor.DocIdPusher;
import com.microsoft.schemas.sharepoint.soap.ObjectType;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.logging.Level;
import java.util.logging.Logger;
class HtmlResponseWriter implements Closeable {
/**
* The number of bytes that may be buffered within the streams. It should
* error on the side of being too large.
*/
private static final long POSSIBLY_BUFFERED_BYTES = 1024;
private static final Logger log
= Logger.getLogger(HtmlResponseWriter.class.getName());
private enum State {
/** Initial state after construction. */
INITIAL,
/**
* {@link #start} was just called, so the HTML header is in place, but no
* other content.
*/
STARTED,
/** {@link #startSection} has been called, so we are within a section. */
IN_SECTION,
/** {@link #finish} has been called, so the HTML footer has been written. */
FINISHED,
/** The writer has been closed. */
CLOSED,
}
private final Writer writer;
private final DocIdEncoder docIdEncoder;
private final Locale locale;
private final long thresholdBytes;
private final CountingOutputStream countingOutputStream;
private final DocIdPusher pusher;
private final Executor executor;
private DocId docId;
private URI docUri;
private State state = State.INITIAL;
private final Collection<DocId> overflowDocIds = new ArrayList<DocId>(1024);
public HtmlResponseWriter(OutputStream os, Charset charset,
DocIdEncoder docIdEncoder, Locale locale, long thresholdBytes,
DocIdPusher pusher, Executor executor) {
if (os == null) {
throw new NullPointerException();
}
if (charset == null) {
throw new NullPointerException();
}
if (docIdEncoder == null) {
throw new NullPointerException();
}
if (locale == null) {
throw new NullPointerException();
}
if (pusher == null) {
throw new NullPointerException();
}
if (executor == null) {
throw new NullPointerException();
}
countingOutputStream = new CountingOutputStream(os);
this.writer = new OutputStreamWriter(countingOutputStream, charset);
this.docIdEncoder = docIdEncoder;
this.locale = locale;
this.thresholdBytes = thresholdBytes;
this.pusher = pusher;
this.executor = executor;
}
/**
* Start writing HTML document.
*
* @param docId the DocId for the document being written out
* @param type type of document referred to by {@code docId}
* @param label possibly-{@code null} title or name of {@code docId}
*/
public void start(DocId docId, ObjectType type, String label)
throws IOException {
if (state != State.INITIAL) {
throw new IllegalStateException("In unexpected state: " + state);
}
this.docId = docId;
this.docUri = docIdEncoder.encodeDocId(docId);
String documentLabel = computeLabel(label, docId);
writer.write("<!DOCTYPE html>\n<html><head><title>");
writer.write(escapeContent(documentLabel));
writer.write("</title></head><body><h1>");
googleoffIndex();
// TODO(ejona): Localize.
writer.write(computeTypeHeaderLabel(type));
googleonIndex();
writer.write(" ");
writer.write(escapeContent(documentLabel));
writer.write("</h1>");
state = State.STARTED;
}
public void startSection(ObjectType type) throws IOException {
if (state != State.STARTED && state != State.IN_SECTION) {
throw new IllegalStateException("In unexpected state: " + state);
}
checkAndCloseSection();
writer.write("<p>");
googleoffIndex();
writer.write(escapeContent(computeTypeSectionLabel(type)));
googleonIndex();
writer.write("</p><ul>");
state = State.IN_SECTION;
}
private void checkAndCloseSection() throws IOException {
if (state == State.IN_SECTION) {
writer.write("</ul>");
}
}
/**
* @param docId docId to add as a link in the document
* @param label possibly-{@code null} title or description of {@code docId}
*/
public void addLink(DocId doc, String label) throws IOException {
if (state != State.IN_SECTION) {
throw new IllegalStateException("In unexpected state: " + state);
}
if (doc == null) {
throw new NullPointerException();
}
if (countingOutputStream.getCount() + POSSIBLY_BUFFERED_BYTES
> thresholdBytes) {
overflowDocIds.add(doc);
}
writer.write("<li><a href=\"");
writer.write(escapeAttributeValue(encodeDocId(doc)));
writer.write("\">");
writer.write(escapeContent(computeLabel(label, doc)));
writer.write("</a></li>");
}
private void addComment(String comment) throws IOException {
writer.write("<!--");
writer.write(escapeContent(comment));
writer.write("-->");
}
private void googleoffIndex() throws IOException {
addComment("googleoff: index");
}
private void googleonIndex() throws IOException {
addComment("googleon: index");
}
public void addMetadata(Multimap<String, String> metadata)
throws IOException {
checkAndCloseSection();
googleoffIndex();
writer.write("<table style='border: none'>");
for (Map.Entry<String, String> me : metadata.entries()) {
writer.write("<tr><td>");
writer.write(escapeContent(me.getKey()));
writer.write("</td><td>");
writer.write(escapeContent(me.getValue()));
writer.write("</td></tr>");
}
writer.write("</table>");
googleonIndex();
state = State.STARTED;
}
/**
* Complete HTML body and flush.
*/
public void finish() throws IOException {
log.entering("HtmlResponseWriter", "finish");
if (state != State.STARTED && state != State.IN_SECTION) {
throw new IllegalStateException("In unexpected state: " + state);
}
if (!overflowDocIds.isEmpty()) {
executor.execute(new Runnable() {
@Override
public void run() {
try {
pusher.pushDocIds(overflowDocIds);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
});
}
checkAndCloseSection();
writer.write("</body></html>");
writer.flush();
state = State.FINISHED;
log.exiting("HtmlResponseWriter", "finish");
}
/**
* Close underlying writer. You will generally want to call {@link #finish}
* first.
*/
@Override
public void close() throws IOException {
log.entering("HtmlResponseWriter", "close");
writer.close();
state = State.CLOSED;
log.exiting("HtmlResponseWriter", "close");
}
/**
* Encodes a DocId into a URI formatted as a string.
*/
private String encodeDocId(DocId doc) {
log.entering("HtmlResponseWriter", "encodeDocId", doc);
URI uri = docIdEncoder.encodeDocId(doc);
uri = relativize(docUri, uri);
String encoded = uri.toASCIIString();
log.exiting("HtmlResponseWriter", "encodeDocId", encoded);
return encoded;
}
/**
* Produce a relative URI from {@code uri} relative to {@code base}, assuming
* both URIs are hierarchial. If possible, a relative URI will be returned
* that can be resolved from {@code base}, otherwise {@code uri} will be
* returned.
*
* <p>Necessary since {@link URI#relativize} is broken when considering
* http://host/path vs http://host/path/ as the base URI. See
* <a href="http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6226081">
* Bug 6226081</a> for more information. In addition, this version uses
* {@code ..} when possible unlike {@link URI#relativize}.
*/
@VisibleForTesting
static URI relativize(URI base, URI uri) {
if (base.getScheme() == null || !base.getScheme().equals(uri.getScheme())
|| base.getAuthority() == null
|| !base.getAuthority().equals(uri.getAuthority())) {
return uri;
}
if (base.equals(uri)) {
return URI.create("#");
}
// These paths are known to start with a / or be the empty string; since the
// URIs have a scheme, we know they are absolute.
String basePath = base.getPath();
String uriPath = uri.getPath();
String[] basePathParts = basePath.split("/", -1);
String[] uriPathParts = uriPath.split("/", -1);
int i = 0;
// Remove common folders. Since we are looking at folders, we don't compare
// the last elements in the array, because they were after the last '/' in
// the URIs.
for (; i < basePathParts.length - 1 && i < uriPathParts.length - 1; i++) {
if (!basePathParts[i].equals(uriPathParts[i])) {
break;
}
}
StringBuilder pathBuilder = new StringBuilder();
for (int j = i; j < basePathParts.length - 1; j++) {
pathBuilder.append("../");
}
for (; i < uriPathParts.length; i++) {
pathBuilder.append(uriPathParts[i]);
pathBuilder.append("/");
}
String path = pathBuilder.substring(0, pathBuilder.length() - 1);
int colonLocation = path.indexOf(":");
int slashLocation = path.indexOf("/");
if (colonLocation != -1
&& (slashLocation == -1 || colonLocation < slashLocation)) {
// If there is a colon before the first slash, then it is easy to confuse
// this relative URI for an absolute URI. Thus, we prepend a ./ so that
// the beginning is obviously not a scheme.
path = "./" + path;
}
try {
return new URI(null, null, path, uri.getQuery(), uri.getFragment());
} catch (URISyntaxException ex) {
throw new AssertionError(ex);
}
}
private String computeLabel(String label, DocId doc) {
if (label == null || "".equals(label)) {
// Use the last part of the URL if an item doesn't have a title. The last
// part of the URL will generally be a filename in this case.
String[] parts = doc.getUniqueId().split("/");
label = parts[parts.length - 1];
}
return label;
}
private String computeTypeHeaderLabel(ObjectType type) {
// TODO(ejona): Localize.
switch (type) {
case VIRTUAL_SERVER:
return "Virtual Server";
case SITE:
return "Site";
case LIST:
return "List";
case FOLDER:
return "Folder";
case LIST_ITEM:
return "List Item";
default:
log.log(Level.WARNING, "Unexpected ObjectType: {0}", type);
return "";
}
}
private String computeTypeSectionLabel(ObjectType type) {
// TODO(ejona): Localize.
switch (type) {
case SITE:
return "Sites";
case LIST:
return "Lists";
case FOLDER:
return "Folders";
case LIST_ITEM:
return "List Items";
case LIST_ITEM_ATTACHMENTS:
return "Attachments";
default:
log.log(Level.WARNING, "Unexpected ObjectType: {0}", type);
return "";
}
}
private String escapeContent(String raw) {
return raw.replace("&", "&amp;").replace("<", "&lt;");
}
private String escapeAttributeValue(String raw) {
return escapeContent(raw).replace("\"", "&quot;").replace("'", "&apos;");
}
}