blob: 166c360a1a4aa9e2e899605553ac3527775e44e4 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.waveprotocol.box.server.robots.operations;
import com.google.common.base.Preconditions;
import com.google.common.collect.Maps;
import com.google.wave.api.Element;
import com.google.wave.api.Gadget;
import com.google.wave.api.InvalidRequestException;
import com.google.wave.api.JsonRpcConstant.ParamsProperty;
import com.google.wave.api.OperationRequest;
import com.google.wave.api.Range;
import com.google.wave.api.data.ApiView;
import com.google.wave.api.data.DocumentHitIterator;
import com.google.wave.api.data.ElementSerializer;
import com.google.wave.api.impl.DocumentModifyAction;
import com.google.wave.api.impl.DocumentModifyAction.BundledAnnotation;
import com.google.wave.api.impl.DocumentModifyAction.ModifyHow;
import com.google.wave.api.impl.DocumentModifyQuery;
import org.waveprotocol.box.server.robots.OperationContext;
import org.waveprotocol.box.server.robots.util.OperationUtil;
import org.waveprotocol.wave.model.conversation.ObservableConversation;
import org.waveprotocol.wave.model.document.Doc;
import org.waveprotocol.wave.model.document.Document;
import org.waveprotocol.wave.model.document.RangedAnnotation;
import org.waveprotocol.wave.model.document.util.LineContainers;
import org.waveprotocol.wave.model.document.util.Point;
import org.waveprotocol.wave.model.document.util.XmlStringBuilder;
import org.waveprotocol.wave.model.gadget.GadgetXmlUtil;
import org.waveprotocol.wave.model.wave.ParticipantId;
import org.waveprotocol.wave.model.wave.opbased.OpBasedWavelet;
import java.util.Map;
/**
* Implements the "document.modify" operations.
*
* <p>
* The documentModify operation has three bits: the where: where is the
* modification applied. a range, index, element or annotation can be specified.
* Any of these is the converted to a range. If nothing appropriate is
* specified, the entire document is taken. the how: do we insert (before),
* append or replace? the what: what is inserted/replaced.
*
* @author ljvderijk@google.com (Lennard de Rijk)
*/
public class DocumentModifyService implements OperationService {
@Override
public void execute(
OperationRequest operation, OperationContext context, ParticipantId participant)
throws InvalidRequestException {
String blipId = OperationUtil.getRequiredParameter(operation, ParamsProperty.BLIP_ID);
DocumentModifyAction modifyAction =
OperationUtil.getRequiredParameter(operation, ParamsProperty.MODIFY_ACTION);
OpBasedWavelet wavelet = context.openWavelet(operation, participant);
ObservableConversation conversation =
context.openConversation(operation, participant).getRoot();
Document doc = context.getBlip(conversation, blipId).getContent();
ApiView view = new ApiView(doc, wavelet);
DocumentHitIterator hitIterator = getDocumentHitIterator(operation, view);
switch (modifyAction.getModifyHow()) {
case ANNOTATE:
annotate(operation, doc, view, hitIterator, modifyAction);
break;
case CLEAR_ANNOTATION:
clearAnnotation(operation, doc, view, hitIterator, modifyAction);
break;
case DELETE:
delete(operation, view, hitIterator);
break;
case INSERT:
insert(operation, doc, view, hitIterator, modifyAction);
break;
case INSERT_AFTER:
insertAfter(operation, doc, view, hitIterator, modifyAction);
break;
case REPLACE:
replace(operation, doc, view, hitIterator, modifyAction);
break;
case UPDATE_ELEMENT:
updateElement(operation, doc, view, hitIterator, modifyAction);
break;
default:
throw new UnsupportedOperationException(
"Unsupported ModifyHow " + modifyAction.getModifyHow());
}
}
/**
* Returns the {@link DocumentHitIterator} for the area that needs to be
* modified as specified in the robot operation.
*
* @param operation the operation that specifies where the modifications need
* to be applied.
* @param view the {@link ApiView} of the document that needs to be modified.
* @throws InvalidRequestException if more than one "where" parameter is
* specified.
*/
private DocumentHitIterator getDocumentHitIterator(OperationRequest operation, ApiView view)
throws InvalidRequestException {
Range range = OperationUtil.getOptionalParameter(operation, ParamsProperty.RANGE);
Integer index = OperationUtil.getOptionalParameter(operation, ParamsProperty.INDEX);
DocumentModifyQuery query =
OperationUtil.getOptionalParameter(operation, ParamsProperty.MODIFY_QUERY);
DocumentHitIterator hitIterator;
if (range != null) {
if (index != null || query != null) {
throw new InvalidRequestException(
"At most one parameter out of RANGE, INDEX and MODIFY_QUERY must be specified",
operation);
}
// Use the specified range
hitIterator = new DocumentHitIterator.Singleshot(range);
} else if (index != null) {
if (query != null) { // range is null.
throw new InvalidRequestException(
"At most one parameter out of RANGE, INDEX and MODIFY_QUERY must be specified",
operation);
}
// Use exactly the location of the index
hitIterator = new DocumentHitIterator.Singleshot(new Range(index, index + 1));
} else if (query != null) { // range and index are both null.
// Use the query
hitIterator = new DocumentHitIterator.ElementMatcher(
view, query.getElementMatch(), query.getRestrictions(), query.getMaxRes());
} else {
// Take entire document since nothing appropriate was specified
hitIterator = new DocumentHitIterator.Singleshot(new Range(0, view.apiContents().length()));
}
return hitIterator;
}
/**
* Annotates the given ranges of the document as indicated by the
* {@link DocumentModifyAction}.
*
* @param operation the operation to execute.
* @param doc the document to annotate.
* @param view the view of the document.
* @param hitIterator iterates over the ranges to annotate, specified in
* {@link ApiView} offset.
* @param modifyAction the {@link DocumentModifyAction} specifying what the
* annotation is.
* @throws InvalidRequestException if the annotation could not be set.
*/
private void annotate(OperationRequest operation, Document doc, ApiView view,
DocumentHitIterator hitIterator, DocumentModifyAction modifyAction)
throws InvalidRequestException {
Preconditions.checkArgument(
modifyAction.getModifyHow() == ModifyHow.ANNOTATE, "This method only supports ANNOTATE");
String annotationKey = modifyAction.getAnnotationKey();
int valueIndex = 0;
Range range = hitIterator.next();
while (range != null) {
int start = view.transformToXmlOffset(range.getStart());
int end = view.transformToXmlOffset(range.getEnd());
setDocumentAnnotation(
operation, doc, start, end, annotationKey, modifyAction.getValue(valueIndex));
valueIndex++;
range = hitIterator.next();
}
}
/**
* Clears the annotation for the given ranges of the document as indicated by
* the {@link DocumentModifyAction}.
*
* @param operation the operation to execute.
* @param doc the document to annotate.
* @param view the view of the document.
* @param hitIterator iterates over the ranges to remove the annotation from,
* specified in {@link ApiView} offset.
* @param modifyAction the {@link DocumentModifyAction} specifying what the
* key of the annotation is annotation is.
* @throws InvalidRequestException if the annotation could not be set.
*/
private void clearAnnotation(OperationRequest operation, Document doc, ApiView view,
DocumentHitIterator hitIterator, DocumentModifyAction modifyAction)
throws InvalidRequestException {
Preconditions.checkArgument(modifyAction.getModifyHow() == ModifyHow.CLEAR_ANNOTATION,
"This method only supports CLEAR_ANNOTATION");
String annotationKey = modifyAction.getAnnotationKey();
Range range = hitIterator.next();
while (range != null) {
int start = view.transformToXmlOffset(range.getStart());
int end = view.transformToXmlOffset(range.getEnd());
setDocumentAnnotation(operation, doc, start, end, annotationKey, null);
range = hitIterator.next();
}
}
/**
* Sets the annotation for a document.
*
* @param operation the operation requesting the annotation to be set.
* @param doc the document to change the annotation in.
* @param start where the annotation should start.
* @param end where the annotation should end.
* @param key the key of the annotation.
* @param value the value of the annotation.
* @throws InvalidRequestException if the annotation could not be set.
*/
private void setDocumentAnnotation(
OperationRequest operation, Document doc, int start, int end, String key, String value)
throws InvalidRequestException {
try {
doc.setAnnotation(start, end, key, value);
} catch (IndexOutOfBoundsException e) {
throw new InvalidRequestException(
"Can't set annotation for out of bounds indices " + e.getMessage(), operation, e);
}
}
/**
* Deletes ranges of elements from a document as specified by the iterator.
*
* @param operation the operation to execute.
* @param view the view of the document.
* @param hitIterator iterates over the ranges of elements to delete.
* @throws InvalidRequestException if the specified range was invalid.
*/
private void delete(OperationRequest operation, ApiView view, DocumentHitIterator hitIterator)
throws InvalidRequestException {
Range range = hitIterator.next();
while (range != null) {
int start = range.getStart();
int end = range.getEnd();
if (start == 0) {
// Can't delete the first new line.
start = 1;
}
if (start >= end) {
throw new InvalidRequestException(
"Invalid range specified, " + start + ">=" + end, operation);
}
// Delete using the view.
view.delete(start, end);
// Shift the iterator to match the updated document.
hitIterator.shift(start, end - start);
range = hitIterator.next();
}
}
/**
* Inserts elements at the position specified by the hitIterator.
*
* @param operation the operation that wants to insert elements.
* @param doc the document to insert elements in.
* @param view the {@link ApiView} of that document.
* @param hitIterator the iterator over the places where to insert.
* @param modifyAction the action that specifies what to insert.
* @throws InvalidRequestException if something goes wrong.
*/
private void insert(OperationRequest operation, Document doc, ApiView view,
DocumentHitIterator hitIterator, DocumentModifyAction modifyAction)
throws InvalidRequestException {
int valueIndex = 0;
Range range = hitIterator.next();
while (range != null) {
int insertAt = range.getStart();
int inserted = insertInto(operation, doc, view, insertAt, modifyAction, valueIndex);
hitIterator.shift(insertAt, inserted);
valueIndex++;
range = hitIterator.next();
}
}
/**
* Inserts elements after the position specified by the hitIterator.
*
* @param operation the operation that wants to insert elements.
* @param doc the document to insert elements in.
* @param view the {@link ApiView} of that document.
* @param hitIterator the iterator over the places where to insert.
* @param modifyAction the action that specifies what to insert.
* @throws InvalidRequestException if something goes wrong.
*/
private void insertAfter(OperationRequest operation, Document doc, ApiView view,
DocumentHitIterator hitIterator, DocumentModifyAction modifyAction)
throws InvalidRequestException {
int valueIndex = 0;
Range range = hitIterator.next();
while (range != null) {
int insertAt = range.getEnd();
int inserted = insertInto(operation, doc, view, insertAt, modifyAction, valueIndex);
hitIterator.shift(insertAt, inserted);
valueIndex++;
range = hitIterator.next();
}
}
/**
* Replaces elements at the position specified by the hitIterator with the
* elements specified in the {@link DocumentModifyAction}.
*
* @param operation the operation that wants to replace elements.
* @param doc the document to replace elements in.
* @param view the {@link ApiView} of that document.
* @param hitIterator the iterator over the places where to replace elements.
* @param modifyAction the action that specifies what to replace the elements
* with.
* @throws InvalidRequestException if something goes wrong.
*/
private void replace(OperationRequest operation, Document doc, ApiView view,
DocumentHitIterator hitIterator, DocumentModifyAction modifyAction)
throws InvalidRequestException {
int valueIndex = 0;
Range range = hitIterator.next();
while (range != null) {
int replaceAt = range.getStart();
int numInserted = insertInto(operation, doc, view, replaceAt, modifyAction, valueIndex);
// Remove the text after what was inserted (so it looks like it has been
// replaced).
view.delete(replaceAt + numInserted, range.getEnd() + numInserted);
// Shift the iterator from the start of the replacement with the amount of
// characters that have been added.
int numRemoved = Math.min(0, range.getStart() - range.getEnd());
hitIterator.shift(replaceAt, numInserted + numRemoved);
valueIndex++;
range = hitIterator.next();
}
}
/**
* Inserts elements into the document at a specified location.
*
* @param operation the operation that wants to insert elements.
* @param doc the document to insert elements in.
* @param view the {@link ApiView} of that document.
* @param insertAt the {@link ApiView} value of where to insert elements.
* @param modifyAction the action that specifies what to insert.
* @param valueIndex the index to use for
* {@link DocumentModifyAction#getValue(int)}, to find out what to
* insert.
* @throws InvalidRequestException if something goes wrong.
*/
private int insertInto(OperationRequest operation, Document doc, ApiView view, int insertAt,
DocumentModifyAction modifyAction, int valueIndex) throws InvalidRequestException {
if (modifyAction.hasTextAt(valueIndex)) {
String toInsert = modifyAction.getValue(valueIndex);
if (insertAt == 0) {
// Make sure that we have a newline as first character.
if (!toInsert.isEmpty() && toInsert.charAt(0) != '\n') {
toInsert = '\n' + toInsert;
}
}
// Insert text.
view.insert(insertAt, toInsert);
// Do something with annotations?
if (modifyAction.getBundledAnnotations() != null) {
int annotationStart = view.transformToXmlOffset(insertAt);
int annotationEnd = view.transformToXmlOffset(insertAt + toInsert.length());
for (RangedAnnotation<String> annotation :
doc.rangedAnnotations(annotationStart, annotationEnd, null)) {
setDocumentAnnotation(
operation, doc, annotationStart, annotationEnd, annotation.key(), null);
}
for (BundledAnnotation ia : modifyAction.getBundledAnnotations()) {
setDocumentAnnotation(operation, doc, annotationStart, annotationEnd, ia.key, ia.value);
}
}
return toInsert.length();
} else {
Element element = modifyAction.getElement(valueIndex);
if (element != null) {
if (element.isGadget()) {
Gadget gadget = (Gadget) element;
XmlStringBuilder xml =
GadgetXmlUtil.constructXml(gadget.getUrl(), "", gadget.getAuthor(), null,
gadget.getProperties());
// TODO (Yuri Z.) Make it possible to specify a location to insert the
// gadget and implement insertion at the specified location.
LineContainers.appendLine(doc, xml);
} else if (element.isFormElement()) {
XmlStringBuilder xml = ElementSerializer.apiElementToXml(element);
LineContainers.appendLine(doc, xml);
} else {
// TODO(ljvderijk): Inserting other elements.
throw new UnsupportedOperationException(
"Can't insert other elements than text and gadgets at the moment");
}
}
// should return 1 since elements have a length of 1 in the ApiView;
return 1;
}
}
/**
* Updates elements in the document.
* <b>Note</b>: Only gadget elements are supported, for now.
*
* @param operation the operation the operation that wants to update elements.
* @param doc the document to update elements in.
* @param view the {@link ApiView} of that document.
* @param hitIterator the iterator over the places where to update elements.
* @param modifyAction the action that specifies what to update.
* @throws InvalidRequestException if something goes wrong.
*/
private void updateElement(OperationRequest operation, Document doc, ApiView view,
DocumentHitIterator hitIterator, DocumentModifyAction modifyAction)
throws InvalidRequestException {
Range range = null;
for (int index = 0; ((range = hitIterator.next()) != null); ++index) {
Element element = modifyAction.getElement(index);
if (element != null) {
if (element.isGadget()) {
int xmlStart = view.transformToXmlOffset(range.getStart());
Doc.E docElem = Point.elementAfter(doc, doc.locate(xmlStart));
updateExistingGadgetElement(doc, docElem, element);
} else {
// TODO (Yuri Z.) Updating other elements.
throw new UnsupportedOperationException(
"Can't update other elements than gadgets at the moment");
}
}
}
}
/**
* Updates the existing gadget element properties.
*
* @param doc the document to update elements in.
* @param existingElement the gadget element to update.
* @param element the element that describes what existingElement should be
* updated with.
* @throws InvalidRequestException
*/
private void updateExistingGadgetElement(Document doc, Doc.E existingElement,
Element element) throws InvalidRequestException {
Preconditions.checkArgument(element.isGadget(),
"Called with non-gadget element type %s", element.getType());
String url = element.getProperty("url");
if (url != null) {
doc.setElementAttribute(existingElement, "url", url);
}
Map<String, Doc.E> children = Maps.newHashMap();
for (Doc.N child = doc.getFirstChild(existingElement); child != null; child =
doc.getNextSibling(child)) {
Doc.E childAsElement = doc.asElement(child);
if (childAsElement != null) {
String key = doc.getTagName(childAsElement);
if (key.equals("state")) {
key = key + " " + doc.getAttributes(childAsElement).get("name");
}
children.put(key, childAsElement);
}
}
for (Map.Entry<String, String> property : element.getProperties().entrySet()) {
// TODO (Yuri Z.) Support updating gadget metadata (author, title, thumbnail...)
// and user preferences.
String key = null;
String tag = null;
if (property.getKey().equals("title") || property.getKey().equals("thumbnail")
|| property.getKey().equals("author")) {
key = property.getKey();
tag = property.getKey();
} else if (!property.getKey().equals("name") && !property.getKey().equals("pref")
&& !property.getKey().equals("url")) {
// A state variable.
key = "state " + property.getKey();
tag = "state";
} else {
continue;
}
String val = property.getValue();
Doc.E child = children.get(key);
if (val == null) {
// Delete the property if value is null.
if (child == null) {
// Property does not exist, skipping.
continue;
}
doc.deleteNode(child);
} else {
if (child != null) {
if (tag.equals("state")) {
doc.setElementAttribute(child, "value", val);
} else {
doc.emptyElement(child);
Point<Doc.N> point = Point.<Doc.N> inElement(child, null);
doc.insertText(point, val);
}
} else {
XmlStringBuilder xml = GadgetXmlUtil.constructStateXml(property.getKey(), val);
doc.insertXml(Point.<Doc.N> inElement(existingElement, null), xml);
}
}
}
}
public static DocumentModifyService create() {
return new DocumentModifyService();
}
}