| // 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.Closeable; |
| import java.io.IOException; |
| import java.io.OutputStream; |
| import java.io.OutputStreamWriter; |
| import java.io.Writer; |
| 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("&", "&").replace("<", "<"); |
| } |
| |
| private String escapeAttributeValue(String raw) { |
| return escapeContent(raw).replace("\"", """).replace("'", "'"); |
| } |
| } |