/**
* Copyright 2008 Mathias Doenitz, http://lis.to/
*
* This file is part of the lis.to java desktop client. The lis.to java desktop client is free software: you can
* redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* The lis.to java desktop client is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with the lis.to java desktop client.
* If not, see http://www.gnu.org/licenses/
*/
package listo.client;
import com.google.common.collect.Lists;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.annotations.XStreamAsAttribute;
import listo.client.model.Context;
import listo.client.model.Folder;
import listo.client.model.ObjectId;
import listo.client.model.operations.AddFolderOp;
import listo.client.model.operations.Operation;
import listo.utils.FileUtils2;
import listo.utils.MiscUtils;
import listo.utils.exceptions.Exceptions;
import listo.utils.logging.Log;
import listo.utils.reflection.Reflector;
import listo.utils.types.DateTime;
import org.apache.commons.lang.StringUtils;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Future;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* The ContextManager orchestrates the interaction of operations on contexts.
* It always keeps a start context (point zero), a list of operations that was performed on this context
* and the resulting current context.
* <p/>
* The ContextManager runs all core operations on its own thread.
* All public methods provide thread-safe access to the current context.
*/
@Singleton
public class ContextManager {
public interface ContextChangedListener {
// CAUTION: This method is being called from the context thread.
public void currentContextChanged(Context context, Operation undoOp, Operation redoOp);
}
private static class SerializationContainer {
@XStreamAsAttribute
public int version = 3;
public Context context;
}
private final Log log;
private final Executors executors;
private final XStream xstream;
private final List<ContextChangedListener> contextChangedListeners = new CopyOnWriteArrayList<ContextChangedListener>();
private Context startContext;
private Context currentContext; // this always is an "immutable" copy of the last current Context
private List<Operation> operations;
private int lastOpIx = -1; // the index of the last operation that was run to reach the current context
@Inject
public ContextManager(final Log log, Reflector reflector, Executors executors) {
this.log = log;
this.executors = executors;
clearAll();
// create and initialize the XStream
xstream = new XStream();
xstream.setMode(XStream.NO_REFERENCES);
xstream.alias("listo-todos", SerializationContainer.class);
xstream.registerConverter(new ObjectId.Converter());
xstream.registerConverter(new DateTime.Converter());
List<Class> modelClasses = reflector.f(Object.class).
getNonAbstractImplementingClasses("listo.client.model", "listo.client.model.operations");
modelClasses.add(SerializationContainer.class);
for (Class modelClass : modelClasses) {
xstream.processAnnotations(modelClass);
}
List<Class> opClasses = reflector.f(Operation.class)
.getNonAbstractImplementingClasses("listo.client.model.operations");
for (Class opClass : opClasses) {
xstream.alias(StringUtils.removeEnd(MiscUtils.getShortClassName(opClass), "Op"), opClass);
}
}
/**
* Gets the current context of user objects.
* The returned object is stable in that it will not change by potential operations happening
* in parallel on the context thread.
*
* @return the current context
*/
public Context getCurrentContext() {
return currentContext;
}
/**
* Forgets the current state and (re)initialized with an empty context and no ops.
*/
public void clearAll() {
executors.getContextExecutor().execute(new Runnable() {
public void run() {
log.debug("Clearing all");
startContext = new Context();
operations = Lists.newArrayList();
resetToStartContext();
notifyListeners();
}
});
}
/**
* Adds and runs the given op.
* First the given operation is added to the end of the operations list if we are currently at the end of the list
* (i.e. there are no more pending ops to be (re)done). If the current state is somewhere before the
* end of all pending operations then the list is truncated at the last op and the given op appended.
* In any case, after the method completes the given op is the new last op in the list.
*
* @param op the op
*/
public void addAndRun(final Operation op) {
executors.getContextExecutor().execute(new Runnable() {
public void run() {
log.debug("Adding and running operation %s", MiscUtils.getShortClassName(op));
removeOpenRedoOps();
operations.add(op);
_redoOne();
notifyListeners();
}
});
}
/**
* @return true if there are more operations in the list, false if this was the last one
*/
private boolean canRedo() {
return lastOpIx < operations.size() - 1;
}
/**
* @return true if there are more operations that can be undone in the list,
* false if there are no more (i.e. this op was the first in the list)
*/
private boolean canUndo() {
return lastOpIx != -1;
}
/**
* Runs the next operation in the list and notifies listeners.
*/
public void redoOne() {
executors.getContextExecutor().execute(new Runnable() {
public void run() {
_redoOne();
notifyListeners();
}
});
}
/**
* Restores the context before the last operation was performed and notifies listeners.
*/
public void undoOne() {
executors.getContextExecutor().execute(new Runnable() {
public void run() {
_undoOne();
notifyListeners();
}
});
}
/**
* Runs the next operation in the list.
*/
private void _redoOne() {
if (!canRedo()) {
log.warn("No more operations to run");
return;
}
Operation op = null;
try {
op = operations.get(lastOpIx + 1);
// we need to run the operation not on the currentContext itself
// but on a copy, which will afterwards become the currentContext
// we need to do this in order to not conflict with other threads
// that might potentially be working on the currentContext in parralel
Context newContext = currentContext.copy();
op.run(newContext);
lastOpIx++;
// the following assignment is atomical, so we do not need to protect it
currentContext = newContext;
}
catch (RuntimeException e) {
log.error("Error executing operation %1$s:\n%2$s", op, Exceptions.getTrace(e));
throw e;
}
}
/**
* Restores the context before the last operation was performed.
*/
private void _undoOne() {
if (!canUndo()) {
log.warn("No more operations to undo");
return;
}
int newLastOpIx = lastOpIx - 1;
resetToStartContext();
while (lastOpIx != newLastOpIx) {
_redoOne();
}
}
/**
* Resets the current context to the start context.
*/
private void resetToStartContext() {
currentContext = startContext.copy(); // atomic assingment, no protection required
lastOpIx = -1;
}
/**
* Prunes all operations that have not yet been redone.
*/
private void removeOpenRedoOps() {
while (lastOpIx < operations.size() - 1) {
operations.remove(lastOpIx + 1);
}
}
/**
* Asyncronously creates all folders in the given path by adding and running the respective ops.
*
* @param pathName the pathName
* @param iconName the icon name
*/
public void createFolderPath(final String pathName, final String iconName) {
executors.getContextExecutor().execute(new Runnable() {
public void run() {
String[] folders = StringUtils.split(pathName, '/');
String currentFolder = null;
for (int i = 0; i < folders.length; i++) {
String folder = folders[i];
Folder parent = StringUtils.isEmpty(currentFolder) ?
getCurrentContext().getRootFolder() :
getCurrentContext().getFolderByPathName(currentFolder);
if (parent == null) throw new IllegalStateException();
currentFolder = StringUtils.isEmpty(currentFolder) ? folder : currentFolder + '/' + folder;
if (getCurrentContext().getFolderByPathName(currentFolder) == null) {
AddFolderOp op = new AddFolderOp();
op.setParent(parent.getId());
op.addField("name", folder);
op.addField("icon", iconName);
addAndRun(op);
if (i < folder.length() - 1) {
executors.getContextExecutor().execute(this);
}
return;
}
}
}
});
}
/**
* Adds the given listener.
*
* @param listener the listener
*/
public void addContextChangedListener(ContextChangedListener listener) {
contextChangedListeners.add(listener);
}
/**
* Adds the given listener.
*
* @param listener the listener
*/
public void removeContextChangedListener(ContextChangedListener listener) {
contextChangedListeners.remove(listener);
}
/**
* Notifies all registered ContextChangedListeners of a change in the context.
*/
private void notifyListeners() {
Operation redoOp = canRedo() ? operations.get(lastOpIx + 1) : null;
Operation undoOp = canUndo() ? operations.get(lastOpIx) : null;
for (ContextChangedListener listener : contextChangedListeners) {
listener.currentContextChanged(currentContext, undoOp, redoOp);
}
}
/**
* Loads the current state from the given file.
*
* @param filename the name of the file
* @return true if successfully loaded
*/
public boolean load(final String filename) {
Future<Boolean> future = executors.getContextExecutor().submit(new Callable<Boolean>() {
public Boolean call() throws Exception {
File file = new File(filename);
if (!file.exists()) {
log.info("Data file %s not found", filename);
return false;
}
try {
log.info("Loading from file %s", file.getCanonicalPath());
String xml = FileUtils2.readAllText(file, Charset.forName("UTF-8"));
xml = migrateXmlVersion(xml);
SerializationContainer container = (SerializationContainer) xstream.fromXML(xml);
startContext = container.context;
operations = new ArrayList<Operation>();
resetToStartContext();
notifyListeners();
return true;
}
catch (FileNotFoundException e) {
throw new IllegalStateException();
}
catch (IOException e) {
throw new RuntimeException(e);
}
catch (Exception e) {
log.error(Exceptions.getTrace(e));
throw new RuntimeException("Corrupt data file " + filename);
}
}
});
try {
return future.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// upgrades older XML text versions to the current format
@SuppressWarnings("fallthrough")
private String migrateXmlVersion(String xml) {
Matcher matcher = Pattern.compile("<listo-todos version=\"(\\d+)\">").matcher(xml);
if (!matcher.find()) throw new RuntimeException("Unknown data file format");
int version = Integer.valueOf(matcher.group(1));
String logMessage = String.format("Upgrading data file XML from version %s", version);
switch (version) {
case 1:
// we changed the case of the Task tag
xml = xml.replace("<Task id=", "<task id=").replace("</Task>", "</task>");
case 2:
// we removed the "verb" and merged it into the "what" field (which was renamed to "desc")
xml = xml.replace("\" what=\"", "\" desc=\"");
matcher = Pattern.compile("verb=\"(.*)\" desc=\"(.*)\"").matcher(xml);
StringBuilder sb = new StringBuilder();
int offset = 0;
while (matcher.find()) {
sb.append(xml.substring(offset, matcher.start()));
sb.append("desc=\"");
sb.append(matcher.group(1)).append(' ');
sb.append(matcher.group(2)).append('\"');
offset = matcher.end();
}
xml = sb.append(xml.substring(offset)).toString();
break;
default:
logMessage = null;
}
if (logMessage != null) log.info(logMessage);
return xml;
}
/**
* Triggers a save operation.
*
* @param filename the file name of the file to save to
*/
public void save(final String filename) {
// we run the save operation in its own parellel thread so as to not
// block anything else with slow disk access
executors.getSaverExecutor().execute(new Runnable() {
public void run() {
log.debug("Performing context save");
SerializationContainer container = new SerializationContainer();
container.context = currentContext;
File dataFile = new File(filename);
File saveFile = dataFile;
File tempFile = null;
if (dataFile.exists()) {
tempFile = new File(dataFile + ".tmp");
saveFile = tempFile;
}
try {
log.debug("Saving to file %1$s", saveFile.getCanonicalPath());
String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + System.getProperty("line.separator") +
xstream.toXML(container);
FileUtils2.writeAllText(xml, saveFile, Charset.forName("UTF-8"));
if (saveFile == tempFile) {
log.debug("Renaming file %1$s to %1$s", saveFile.getCanonicalPath(),
dataFile.getCanonicalPath());
dataFile.delete();
saveFile.renameTo(dataFile);
}
}
catch (FileNotFoundException e) {
throw new IllegalStateException();
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
});
}
}
|