/** * 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.box.consoleclient; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import com.google.inject.Inject; import jline.ANSIBuffer; import jline.Completor; import jline.ConsoleReader; import org.waveprotocol.box.common.DocumentConstants; import org.waveprotocol.box.common.IndexEntry; import org.waveprotocol.box.common.IndexWave; import org.waveprotocol.box.common.comms.WaveClientRpc.ProtocolSubmitResponse; import org.waveprotocol.box.consoleclient.ScrollableWaveView.RenderMode; import org.waveprotocol.box.server.util.BlockingSuccessFailCallback; import org.waveprotocol.box.server.util.WaveletDataUtil; import org.waveprotocol.wave.model.id.WaveId; import org.waveprotocol.wave.model.operation.wave.AddParticipant; import org.waveprotocol.wave.model.operation.wave.BlipOperation; import org.waveprotocol.wave.model.operation.wave.RemoveParticipant; import org.waveprotocol.wave.model.operation.wave.WaveletOperation; import org.waveprotocol.wave.model.version.HashedVersion; import org.waveprotocol.wave.model.wave.ParticipantId; import org.waveprotocol.wave.model.wave.data.BlipData; 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 { /** Interface for any class that can render the console client */ public interface Renderer { /** * Renders the console client. * * @param client to render. */ void render(ConsoleClient client); } /** * Single active client-server interface, or null when not connected to a * server. */ private ClientBackend backend = null; /** * A factory used to construct the client backend instance. */ private final ClientBackend.Factory backendFactory; /** * Single active console reader. */ private final ConsoleReader reader; /** * The renderer for this client. */ private final Renderer renderer; /** * Currently open wave. */ private ScrollableWaveView openWave; /** * Inbox we are rendering. */ private ScrollableInbox inbox; /** * Number of lines to scroll by with { and }. */ private final 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 [password]", "connect to server:port as user@domain, optionally with specified password"), 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"), // Temporarily disabled: // 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")); /** * The default console client renderer */ public static class DefaultRenderer implements Renderer { @Override public void render(ConsoleClient client) { final ConsoleReader reader = client.reader; final ScrollableInbox inbox = client.inbox; final ScrollableWaveView openWave = client.openWave; final int canvasWidth = getCanvasWidth(reader); final int canvasHeight = getCanvasHeight(reader); final 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 (inbox != null) { inbox.setOpenWave((openWave == null) ? null : openWave.getWave()); inboxRender = inbox.render(canvasWidth, canvasHeight); } else { inboxRender = Lists.newArrayList(); ConsoleUtils.ensureHeight(canvasWidth, canvasHeight, inboxRender); } if (openWave != null) { waveRender = openWave.render(canvasWidth, canvasHeight); } else { waveRender = Lists.newArrayList(); ConsoleUtils.ensureHeight(canvasWidth, canvasHeight, 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()); System.out.print(buf); System.out.flush(); } /** * @return the width of the "canvas", how wide a single rendering panel is */ private int getCanvasWidth(ConsoleReader reader) { return (reader.getTermwidth() / 2) - 2; // There are 2 panels, then leave some space. } /** * @return the height of the "canvas", how high a single rendering panel is */ private int getCanvasHeight(ConsoleReader reader) { 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(); } } /** * Create new console client with the default backend factory and renderer. */ public ConsoleClient() throws IOException { this(new ClientBackend.DefaultFactory(), new DefaultRenderer()); } /** * Create new console client with the given backend factory and renderer. */ @Inject public ConsoleClient(ClientBackend.Factory backendFactory, Renderer renderer) throws IOException { reader = new ConsoleReader(); this.backendFactory = backendFactory; this.renderer = renderer; // 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 == 2) { connect(args[0], null, args[1]); } else if (args.length != 0) { System.out.println("Usage: java ConsoleClient [user@domain server:port]"); shutdown(1); } for (String line = reader.readLine(); line != null; line = reader.readLine()) { processLine(line); } shutdown(0); } /** * Processes the given command line and performs the appropriate action * * @param line command line string */ @VisibleForTesting void processLine(String line) { if (line.startsWith("/")) { doCommand(extractCmd(line), extractArgs(line)); } else if (line.length() > 0) { sendAppendBlipDelta(line); } else { if (isWaveOpen()) { openWave.scrollToTop(); } render(); } } /** * 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); } private char[] readPassword() throws IOException { // We can't use the reader without resetting the prompt. String oldPrompt = reader.getDefaultPrompt(); char[] pwd = reader.readLine("Enter password: ", (char) 0).toCharArray(); reader.setDefaultPrompt(oldPrompt); return pwd; } /** * 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() == 2) { connect(args.get(0), null, args.get(1)); } else if (args.size() == 3) { // This is used in testing to make sure we don't actually bug the user. connect(args.get(0), args.get(2).toCharArray(), args.get(1)); } 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(); // "Undo" temporarily disabled. // } 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")) { shutdown(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; } /** * Attempt to login. Blocks until login complete. * * Returns false if authentication fails, true otherwise. * * If suppliedPassword is set, it will be used to login. * If suppliedPassword is null, the user will be queried for a password. They are * given 3 attempts to login, after which the method returns false. * * @param suppliedPassword A password to login with, or null. * @return true if login succeeded, false otherwise. */ private boolean login(char[] suppliedPassword) throws IOException { boolean authResult = false; int attempts = 0; do { char[] password = suppliedPassword != null ? suppliedPassword : readPassword(); authResult = backend.authenticate(password); if (authResult == false) { out.println("Login failed."); // We'll give the user 3 attempts to login. attempts++; } } while (authResult == false && suppliedPassword == null && attempts < 3); return authResult; } /** * Register a user and server with a new {@link ClientBackend}. * * @param suppliedPassword the user's password, or null to have connect() * query the user automatically. If supplied password is set, the user * is never queried. */ private void connect(final String userAtDomain, char[] suppliedPassword, String serverAddress) { // We can only connect to one server at a time (at least, in this simple UI). if (isConnected()) { out.println("Warning: already connected. Disconnecting."); disconnect(); } try { backend = backendFactory.create(userAtDomain, serverAddress); backend.addWaveletOperationListener(this); } catch (IOException e) { out.println("Error: failed to connect, " + e.getMessage()); return; } try { if (!login(suppliedPassword)) { out.println("Error: Authentication failed. Username / password invalid."); return; } } catch (IOException e) { out.println("Error: failed to contact to authentication server: " + e.getMessage()); return; } reader.setDefaultPrompt( ConsoleUtils.ansiWrap(ConsoleUtils.ANSI_RED_FG, userAtDomain + "> ")); inbox = new ScrollableInbox(backend, backend.getIndexWave()); render(); } /** * Disconnects the client */ @VisibleForTesting void disconnect() { if (backend != null) { backend.shutdown(); backend = null; openWave = null; inbox = null; } } /** * @return the client backend. */ @VisibleForTesting ClientBackend getBackend() { return backend; } /** * @return the wave ID of the currently open wave. */ @VisibleForTesting WaveId getOpenWaveId() { // Throw NPE if no wave is open. return openWave.getWave().getWaveId(); } /** * @return the wave ID of the currently open wave. */ @VisibleForTesting ScrollableWaveView.RenderMode getRenderingMode() { // Throw NPE if no wave is open. return openWave.getRenderingMode(); } /** * @return the current number of lines to scroll by with { and }. */ @VisibleForTesting int getScrollLines() { return scrollLines.get(); } /** * @return true if the inbox is open. */ @VisibleForTesting boolean isInboxOpen() { return (inbox != null); } /** * Checks if the specified wave has been read. * * @param wave to check. * @return true if the wave was read. */ @VisibleForTesting boolean isRead(ClientWaveView wave) { return (isInboxOpen()) && inbox.isRead(wave); } /** * Shut down the client and exits * * @param exitStatus to quit with */ private void shutdown(int exitStatus) { disconnect(); System.exit(exitStatus); } /** * 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()) { WaveletOperation[] ops = ClientUtils.createAppendBlipOps(getManifestDocument(), backend.getIdGenerator().newBlipId(), text, backend.createOperationContext()); backend.sendAndAwaitWaveletOperations(WaveletDataUtil.waveletNameOf(getOpenWavelet()), 1, TimeUnit.MINUTES, ops); } else { errorNoWaveOpen(); } } /** * @return open document, or null if no wave is open or main document doesn't * exist */ private BlipData getManifestDocument() { return getOpenWavelet() == null ? null : getOpenWavelet().getDocument( DocumentConstants.MANIFEST_DOCUMENT_ID); } /** * @return the open wavelet of the open wave, or null if no wave is open */ private WaveletData getOpenWavelet() { return isWaveOpen() ? ClientUtils.getConversationRoot(openWave.getWave()) : null; } /** * 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 = IndexWave.getIndexEntries(backend.getIndexWave().getWavelets()); 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) { openWave = null; } else { 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()) { name = ensureHasDomain(name); ParticipantId addId = ParticipantId.ofUnsafe(name); // Don't send an invalid op, although the server should be robust enough // to deal with it if (!getOpenWavelet().getParticipants().contains(addId)) { backend.sendAndAwaitWaveletOperations(WaveletDataUtil.waveletNameOf(getOpenWavelet()), 1, TimeUnit.MINUTES, new AddParticipant(backend.createOperationContext(), addId)); } 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()) { name = ensureHasDomain(name); ParticipantId removeId = ParticipantId.ofUnsafe(name); if (getOpenWavelet().getParticipants().contains(removeId)) { backend.sendAndAwaitWaveletOperations(WaveletDataUtil.waveletNameOf(getOpenWavelet()), 1, TimeUnit.MINUTES, new RemoveParticipant(backend.createOperationContext(), removeId)); } else { out.println("Error: " + name + " is not a participant on this wave"); } } else { errorNoWaveOpen(); } } /** * Ensures that a domain name is present in the returned string, by either * returning the given string or by appending the domain name of the local * user. * * @param address the address to ensure has a domain * @return {@link String} which at least contains the domain prefix. */ private String ensureHasDomain(String address) { // Ensure that the given name has an explicit domain if (!address.contains(ParticipantId.DOMAIN_PREFIX)) { // No domain so add the domain of the local user String localDomain = backend.getUserId().getDomain(); address += ParticipantId.DOMAIN_PREFIX + localDomain; } return address; } /** * 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.markAllAsRead(); render(); } else { errorNotConnected(); } } // "Undo" temporarily disabled. // /** // * 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 */ @VisibleForTesting boolean isConnected() { return backend != null; } /** * @return whether the client has a wave open (and displayed in the UI) */ @VisibleForTesting boolean isWaveOpen() { return (openWave != null); } /** * Render everything (inbox, open wave, input). */ private void render() { renderer.render(this); } @Override public void waveletDocumentUpdated(String author, WaveletData wavelet, String docId, BlipOperation 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.getWaveId().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); } } }