/**
* Copyright 2009 Google Inc.
*
* 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 org.waveprotocol.wave.examples.fedone.waveclient.console;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import jline.ANSIBuffer;
import jline.Completor;
import jline.ConsoleReader;
import org.waveprotocol.wave.examples.fedone.common.DocumentConstants;
import org.waveprotocol.wave.examples.fedone.common.HashedVersion;
import org.waveprotocol.wave.examples.fedone.util.BlockingSuccessFailCallback;
import org.waveprotocol.wave.examples.fedone.waveclient.common.ClientBackend;
import org.waveprotocol.wave.examples.fedone.waveclient.common.ClientUtils;
import org.waveprotocol.wave.examples.fedone.waveclient.common.ClientWaveView;
import org.waveprotocol.wave.examples.fedone.waveclient.common.IndexEntry;
import org.waveprotocol.wave.examples.fedone.waveclient.common.WaveletOperationListener;
import org.waveprotocol.wave.examples.fedone.waveclient.console.ScrollableWaveView.RenderMode;
import org.waveprotocol.wave.examples.fedone.waveserver.WaveClientRpc.ProtocolSubmitResponse;
import org.waveprotocol.wave.model.document.operation.BufferedDocOp;
import org.waveprotocol.wave.model.document.operation.impl.AttributesImpl;
import org.waveprotocol.wave.model.document.operation.impl.DocOpBuilder;
import org.waveprotocol.wave.model.operation.wave.AddParticipant;
import org.waveprotocol.wave.model.operation.wave.RemoveParticipant;
import org.waveprotocol.wave.model.operation.wave.WaveletDocumentOperation;
import org.waveprotocol.wave.model.wave.ParticipantId;
import org.waveprotocol.wave.model.wave.data.WaveletData;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.IOException;
import java.io.PrintStream;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* User interface for the console client using the JLine library.
*/
public class ConsoleClient implements WaveletOperationListener {
/**
* Single active client-server interface, or null when not connected to a
* server.
*/
private ClientBackend backend = null;
/**
* Single active console reader.
*/
private final ConsoleReader reader;
/**
* Currently open wave.
*/
private ScrollableWaveView openWave;
/**
* Inbox we are rendering.
*/
private ScrollableInbox inbox;
/**
* Number of lines to scroll by with { and }.
*/
private static AtomicInteger scrollLines = new AtomicInteger(1);
/**
* PrintStream to use for output. We don't use ConsoleReader's functionality
* because it's too verbose and doesn't really give us anything in return.
*/
private final PrintStream out = System.out;
private class Command {
public final String name;
public final String args;
public final String description;
private Command(String name, String args, String description) {
this.name = name;
this.args = args;
this.description = description;
}
}
/**
* Commands available to the user.
*/
private final List<Command> commands = ImmutableList.of(
new Command("connect", "user@domain server port",
"connect to server:port as user@domain"),
new Command("open", "entry", "open a wave given an inbox entry"),
new Command("new", "", "create a new wave"),
new Command("add", "user@domain", "add a user to a wave"),
new Command("remove", "user@domain", "remove a user from a wave"),
new Command("read", "", "set all waves as read"),
// new Command("undo", "[user@domain]",
// "undo last line by a user, defaulting to current user"),
new Command("scroll", "lines",
"set the number of lines to scroll by with { and }"),
new Command("view", "mode",
"change view mode for the open wavelet (normal, xml)"),
new Command("log", "", "dump the log to the screen"),
new Command("dumplog", "file", "dump the log to a file"),
new Command("clearlog", "", "clear the log"),
new Command("quit", "", "quit the client"));
/**
* Create new console client.
*/
public ConsoleClient() throws IOException {
reader = new ConsoleReader();
// Set up scrolling -- these are the opposite to how you would expect because of the way that
// the waves are scrolled (where the bottom is treated as the top)
reader.addTriggeredAction('}', new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
if (isWaveOpen()) {
openWave.scrollUp(scrollLines.get());
render();
}
}
});
reader.addTriggeredAction('{', new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
if (isWaveOpen()) {
openWave.scrollDown(scrollLines.get());
render();
}
}
});
// And tab completion
reader.addCompletor(new Completor() {
@SuppressWarnings("unchecked")
@Override
public int complete(String buffer, int cursor, List candidates) {
if (buffer.trim().startsWith("/")) {
buffer = buffer.trim().substring(1);
}
for (Command cmd : commands) {
if (cmd.name.startsWith(buffer)) {
candidates.add('/' + cmd.name + ' ');
}
}
return 0;
}
});
}
/**
* Entry point for the user interface, receives user input, terminates on
* EOF.
*
* @param args command line arguments
*/
public void run(String[] args) throws IOException {
// Initialise screen and move cursor to bottom left corner
reader.clearScreen();
reader.setDefaultPrompt("(not connected) ");
out.println(ANSIBuffer.ANSICodes.gotoxy(reader.getTermheight(), 1));
// Immediately establish connection if desired, otherwise the user will need to use "/connect"
if (args.length == 3) {
connect(args[0], args[1], args[2]);
} else if (args.length != 0) {
System.out.println("Usage: java ConsoleClient [user@domain server port]");
System.exit(1);
}
for (String line = reader.readLine(); line != null;
line = reader.readLine()) {
if (line.startsWith("/")) {
doCommand(extractCmd(line), extractArgs(line));
} else if (line.length() > 0) {
sendAppendBlipDelta(line);
} else {
if (isWaveOpen()) {
openWave.scrollToTop();
}
render();
}
}
if (isConnected()) {
backend.shutdown();
}
// And yet there still seem to be threads hanging around
System.exit(0);
}
/**
* Extract the command from a command line String.
*
* For example, extractCmd("/connect hello") returns "connect".
*
* @param commandLine the command line input
* @return command
*/
private String extractCmd(String commandLine) {
return extractCmdBits(commandLine).get(0).substring(1);
}
/**
* Extract the command arguments from a command line String.
*
* For example, extractArgs("/connect hello") returns ["hello"].
*
* @param commandLine the command line input
* @return list of command arguments
*/
private List<String> extractArgs(String commandLine) {
List<String> bits = extractCmdBits(commandLine);
return bits.subList(1, bits.size());
}
/**
* Extract a list of command line components from a command line String.
*
* For example, extractCmdBits("/connect hello") return ["/connect",
* "hello"].
*
* @param commandLine the command line input
* @return list of command line components
*/
private List<String> extractCmdBits(String commandLine) {
return Arrays.asList(commandLine.trim().split(" +"));
}
/**
* Perform command with given arguments.
*
* @param cmd command string to perform
* @param args list of arguments to the command
*/
private void doCommand(String cmd, List<String> args) {
if (cmd.equals("connect")) {
if (args.size() == 3) {
connect(args.get(0), args.get(1), args.get(2));
} else {
badArgs(cmd);
}
} else if (cmd.equals("open")) {
if (args.size() == 1) {
try {
doOpenWave(Integer.parseInt(args.get(0)));
} catch (NumberFormatException e) {
out.println("Error: " + args.get(0) + " is not a number");
}
} else {
badArgs(cmd);
}
} else if (cmd.equals("new")) {
newWave();
} else if (cmd.equals("add")) {
if (args.size() == 1) {
addParticipant(args.get(0));
} else {
badArgs(cmd);
}
} else if (cmd.equals("remove")) {
if (args.size() == 1) {
removeParticipant(args.get(0));
} else {
badArgs(cmd);
}
} else if (cmd.equals("view")) {
if (args.size() == 1) {
setView(args.get(0));
} else {
badArgs(cmd);
}
} else if (cmd.equals("read")) {
readAllWaves();
// } else if (cmd.equals("undo")) {
// if (args.size() == 1) {
// undo(args.get(0));
// } else if (backend != null) {
// undo(backend.getUserId().getAddress());
// } else {
// errorNotConnected();
// }
} else if (cmd.equals("scroll")) {
if (args.size() == 1) {
setScrollLines(args.get(0));
} else {
badArgs(cmd);
}
} else if (cmd.equals("log")) {
out.print(ClientBackend.getLog());
} else if (cmd.equals("dumplog")) {
if (args.size() == 1) {
try {
new PrintStream(args.get(0)).print(ClientBackend.getLog());
} catch (IOException e) {
out.println("Couldn't write log to " + args.get(0) + ": " + e);
}
} else {
badArgs(cmd);
}
} else if (cmd.equals("clearlog")) {
ClientBackend.clearLog();
} else if (cmd.equals("quit")) {
System.exit(0);
} else {
printHelp();
}
}
/**
* Print help.
*/
private void printHelp() {
int maxNameLength = 0;
int maxArgsLength = 0;
for (Command cmd : commands) {
maxNameLength = Math.max(maxNameLength, cmd.name.length());
maxArgsLength = Math.max(maxArgsLength, cmd.args.length());
}
out.println("Commands:");
for (Command cmd : commands) {
out.printf(String.format(" %%-%ds %%-%ds %%s\n", maxNameLength, maxArgsLength), cmd.name,
cmd.args, cmd.description);
}
out.println();
out.println("Scrolling:");
out.println(" { scroll up open wave");
out.println(" } scroll down open wave");
out.println();
}
/**
* Print some error message when there are bad arguments to a user interface
* command.
*
* @param cmd the bad command
*/
private void badArgs(String cmd) {
out.println("Error: incorrect number of arguments to " + cmd + ", expecting: /" + cmd + " "
+ findCommand(cmd).args);
}
/**
* @param command name
* @return the {@code Command} object from commands for command, or null if
* not found
*/
private Command findCommand(String command) {
for (Command cmd : commands) {
if (command.equals(cmd.name)) {
return cmd;
}
}
return null;
}
/**
* Register a user and server with a new {@link ClientBackend}.
*/
private void connect(String userAtDomain, String server, String portString) {
// We can only connect to one server at a time (at least, in this simple UI)
if (isConnected()) {
out.println("Warning: already connected");
backend.shutdown();
backend = null;
openWave = null;
inbox = null;
}
int port;
try {
port = Integer.parseInt(portString);
} catch (NumberFormatException e) {
out.println("Error: must provide valid port");
return;
}
try {
backend = new ClientBackend(userAtDomain, server, port);
} catch (IOException e) {
out.println("Error: failed to connect, " + e.getMessage());
return;
}
backend.addWaveletOperationListener(this);
reader.setDefaultPrompt(
ConsoleUtils.ansiWrap(ConsoleUtils.ANSI_RED_FG, userAtDomain + "> "));
inbox = new ScrollableInbox(backend, backend.getIndexWave());
render();
}
/**
* Create and send a mutation that creates a new blip containing the given text, places it in a
* new blip, then adds a referece to the blip in the document manifest.
*
* @param text the text to include in the new blip
*/
private void sendAppendBlipDelta(String text) {
if (isWaveOpen()) {
backend.sendAndAwaitWaveletDelta(getOpenWavelet().getWaveletName(),
ClientUtils.createAppendBlipDelta(getManifestDocument(), backend.getUserId(),
backend.getIdGenerator().newDocumentId(), text), 1, TimeUnit.MINUTES);
} else {
errorNoWaveOpen();
}
}
/**
* @return the open wavelet of the open wave, or null if no wave is open
*/
private WaveletData getOpenWavelet() {
return (openWave == null) ? null : ClientUtils
.getConversationRoot(openWave.getWave());
}
/**
* @return open document, or null if no wave is open or main document doesn't exist
*/
private BufferedDocOp getManifestDocument() {
return getOpenWavelet() == null ? null : getOpenWavelet().getDocuments().get(
DocumentConstants.MANIFEST_DOCUMENT_ID);
}
/**
* Open a wave with a given entry (index in the inbox).
*
* @param entry into the inbox
*/
private void doOpenWave(int entry) {
if (isConnected()) {
List<IndexEntry> index = ClientUtils.getIndexEntries(backend.getIndexWave());
if (entry >= index.size()) {
out.print("Error: entry is out of range, ");
if (index.isEmpty()) {
out.println("there are no available waves (try \"/new\")");
} else {
out.println("expecting [0.." + (index.size() - 1) + "] (for example, \"/open 0\")");
}
} else {
setOpenWave(backend.getWave(index.get(entry).getWaveId()));
}
} else {
errorNotConnected();
}
}
/**
* Set a wave as the open wave.
*
* @param wave to set as open
*/
private void setOpenWave(ClientWaveView wave) {
if (ClientUtils.getConversationRoot(wave) == null) {
wave.createWavelet(ClientUtils.getConversationRootId(wave));
}
openWave = new ScrollableWaveView(wave);
render();
}
/**
* Add a new wave.
*/
private void newWave() {
if (isConnected()) {
BlockingSuccessFailCallback<ProtocolSubmitResponse, String> callback =
BlockingSuccessFailCallback.create();
backend.createConversationWave(callback);
callback.await(1, TimeUnit.MINUTES);
} else {
errorNotConnected();
}
}
/**
* Add a participant to the currently open wave(let).
*
* @param name name of the participant to add
*/
private void addParticipant(String name) {
if (isWaveOpen()) {
ParticipantId addId = new ParticipantId(name);
// Don't send an invalid op, although the server should be robust enough to deal with it
if (!getOpenWavelet().getParticipants().contains(addId)) {
backend.sendAndAwaitWaveletOperation(getOpenWavelet().getWaveletName(),
new AddParticipant(addId), 1, TimeUnit.MINUTES);
} else {
out.println("Error: " + name + " is already a participant on this wave");
}
} else {
errorNoWaveOpen();
}
}
/**
* Remove a participant from the currently open wave(let).
*
* @param name name of the participant to remove
*/
private void removeParticipant(String name) {
if (isWaveOpen()) {
ParticipantId removeId = new ParticipantId(name);
if (getOpenWavelet().getParticipants().contains(removeId)) {
backend.sendAndAwaitWaveletOperation(getOpenWavelet().getWaveletName(),
new RemoveParticipant(removeId), 1, TimeUnit.MINUTES);
} else {
out.println("Error: " + name + " is not a participant on this wave");
}
} else {
errorNoWaveOpen();
}
}
/**
* Set the view type for the open wavelet.
*
* @param mode for rendering
*/
private void setView(String mode) {
if (isWaveOpen()) {
if (mode.equals("normal")) {
openWave.setRenderingMode(RenderMode.NORMAL);
render();
} else if (mode.equals("xml")) {
openWave.setRenderingMode(RenderMode.XML);
render();
} else {
out.println("Error: unsupported rendering, run \"?\"");
}
} else {
errorNoWaveOpen();
}
}
/**
* Set all waves as read.
*/
private void readAllWaves() {
if (isConnected()) {
inbox.updateHashedVersions();
render();
} else {
errorNotConnected();
}
}
// /**
// * Undo last line (line elements and text) sent by a user.
// *
// * @param userId of user
// */
// private void undo(String userId) {
// if (isWaveOpen()) {
// if (getOpenWavelet().getParticipants()
// .contains(new ParticipantId(userId))) {
// undoLastLineBy(userId);
// } else {
// out.println("Error: " + userId + " is not a participant of this wave");
// }
// } else {
// errorNoWaveOpen();
// }
// }
// /**
// * Do the real work for undo.
// *
// * @param userId of user
// */
// private void undoLastLineBy(final String userId) {
// if (getOpenDocument() == null) {
// out.println("Error: document is empty");
// return;
// }
//
// // Find the last line written by the participant given by userId (by counting the number of
// // <line></line> elements, and comparing to their authors).
// final AtomicInteger totalLines = new AtomicInteger(0);
// final AtomicInteger lastLine = new AtomicInteger(-1);
//
// getOpenDocument().apply(new InitializationCursorAdapter(
// new DocInitializationCursor() {
// @Override
// public void elementStart(String type, Attributes attrs) {
// if (type.equals(ConsoleUtils.LINE)) {
// totalLines.incrementAndGet();
//
// if (userId.equals(attrs.get(ConsoleUtils.LINE_AUTHOR))) {
// lastLine.set(totalLines.get() - 1);
// }
// }
// }
//
// @Override
// public void characters(String s) {
// }
//
// @Override
// public void annotationBoundary(AnnotationBoundaryMap map) {
// }
//
// @Override
// public void elementEnd() {
// }
// }));
//
// // Delete the line
// if (lastLine.get() >= 0) {
// WaveletDocumentOperation
// undoOp =
// new WaveletDocumentOperation(MAIN_DOCUMENT_ID,
// ConsoleUtils.createLineDeletion(
// getOpenDocument(), lastLine.get()));
// backend.sendWaveletOperation(getOpenWavelet().getWaveletName(), undoOp);
// } else {
// out.println("Error: " + userId + " hasn't written anything yet");
// }
// }
/**
* Set the number of lines to scroll by.
*
* @param lines to scroll by
*/
public void setScrollLines(String lines) {
try {
scrollLines.set(Integer.parseInt(lines));
} catch (NumberFormatException e) {
out.println("Error: lines must be a valid integer");
}
}
/**
* Print error message if user is not connected to a server.
*/
private void errorNotConnected() {
out.println("Error: not connected, run \"/connect user@domain server port\"");
}
/**
* Print error message if user does not have a wave open.
*/
private void errorNoWaveOpen() {
out.println("Error: no wave is open, run \"/open index\"");
}
/**
* @return whether the client is connected to any server
*/
private boolean isConnected() {
return backend != null;
}
/**
* @return whether the client has a wave open
*/
private boolean isWaveOpen() {
return isConnected() && openWave != null;
}
/**
* Render everything (inbox, open wave, input).
*/
private void render() {
StringBuilder buf = new StringBuilder();
// Clear screen
buf.append(ANSIBuffer.ANSICodes.save());
buf.append(ANSIBuffer.ANSICodes.gotoxy(1, 1));
buf.append(((char) 27) + "[J");
// Render inbox and wave size by side
List<String> inboxRender;
List<String> waveRender;
if (isConnected() && backend.getIndexWave() != null) {
inbox.setOpenWave(openWave == null ? null : openWave.getWave());
inboxRender = inbox.render(getCanvassWidth(), getCanvassHeight());
} else {
inboxRender = Lists.newArrayList();
ConsoleUtils.ensureHeight(getCanvassWidth(), getCanvassHeight(), inboxRender);
}
if (isWaveOpen()) {
waveRender = openWave.render(getCanvassWidth(), getCanvassHeight());
} else {
waveRender = Lists.newArrayList();
ConsoleUtils.ensureHeight(getCanvassWidth(), getCanvassHeight(), waveRender);
}
buf.append(renderSideBySide(inboxRender, waveRender));
// Draw what the user was typing at the time of rendering
buf.append(ANSIBuffer.ANSICodes.gotoxy(reader.getTermheight(), 1));
buf.append(reader.getDefaultPrompt());
buf.append(reader.getCursorBuffer());
// Restore cursor
buf.append(ANSIBuffer.ANSICodes.restore());
out.print(buf);
out.flush();
}
/**
* @return the width of the "canvass", how wide a single rendering panel is
*/
private int getCanvassWidth() {
return (reader.getTermwidth() / 2) - 2; // there are 2 panels, then leave some space
}
/**
* @return the height of the "canvass", how high a single rendering panel is
*/
private int getCanvassHeight() {
return reader.getTermheight() - 1; // subtract a line for the input
}
/**
* Render two list of Strings (lines) side by side.
*
* @param left column
* @param right column
* @return rendered columns
*/
private String renderSideBySide(List<String> left, List<String> right) {
StringBuilder rendered = new StringBuilder();
if (left.size() != right.size()) {
throw new IllegalArgumentException("Left and right are different heights");
}
for (int i = 0; i < left.size(); i++) {
rendered.append(left.get(i));
rendered.append(" | ");
rendered.append(right.get(i));
rendered.append("\n");
}
return rendered.toString();
}
@Override
public void waveletDocumentUpdated(String author, WaveletData wavelet,
WaveletDocumentOperation docOp) {
// TODO(arb): record the author??
}
@Override
public void participantAdded(String author, WaveletData wavelet, ParticipantId participantId) {
}
@Override
public void participantRemoved(String author, WaveletData wavelet, ParticipantId participantId) {
if (isWaveOpen() && participantId.equals(backend.getUserId())) {
// We might have been removed from our open wave (an impressively verbose check...)
if (wavelet.getWaveletName().waveId.equals(openWave.getWave().getWaveId())) {
openWave = null;
}
}
}
@Override
public void noOp(String author, WaveletData wavelet) {
}
@Override
public void onDeltaSequenceStart(WaveletData wavelet) {
}
@Override
public void onDeltaSequenceEnd(WaveletData wavelet) {
render();
}
@Override
public void onCommitNotice(WaveletData wavelet, HashedVersion version) {
}
public static void main(String[] args) {
try {
ConsoleClient ui = new ConsoleClient();
ui.run(args);
} catch (IOException e) {
System.err.println("IOException when running client: " + e);
}
}
}