Merge branch 'next' into dev/image
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 20 Sep 2011 20:03:35 +0000 (22:03 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 20 Sep 2011 20:03:35 +0000 (22:03 +0200)
21 files changed:
1  2 
pom.xml
src/main/java/net/pterodactylus/sone/core/Core.java
src/main/java/net/pterodactylus/sone/core/CoreListener.java
src/main/java/net/pterodactylus/sone/core/CoreListenerManager.java
src/main/java/net/pterodactylus/sone/core/FreenetInterface.java
src/main/java/net/pterodactylus/sone/core/SoneDownloader.java
src/main/java/net/pterodactylus/sone/core/SoneException.java
src/main/java/net/pterodactylus/sone/core/SoneInserter.java
src/main/java/net/pterodactylus/sone/data/Sone.java
src/main/java/net/pterodactylus/sone/web/CreateAlbumPage.java
src/main/java/net/pterodactylus/sone/web/DeleteAlbumPage.java
src/main/java/net/pterodactylus/sone/web/DeleteImagePage.java
src/main/java/net/pterodactylus/sone/web/EditAlbumPage.java
src/main/java/net/pterodactylus/sone/web/EditImagePage.java
src/main/java/net/pterodactylus/sone/web/GetImagePage.java
src/main/java/net/pterodactylus/sone/web/ImageBrowserPage.java
src/main/java/net/pterodactylus/sone/web/UploadImagePage.java
src/main/java/net/pterodactylus/sone/web/WebInterface.java
src/main/resources/i18n/sone.en.properties
src/main/resources/static/css/sone.css
src/main/resources/templates/viewSone.html

diff --combined pom.xml
+++ b/pom.xml
@@@ -2,12 -2,12 +2,12 @@@
        <modelVersion>4.0.0</modelVersion>
        <groupId>net.pterodactylus</groupId>
        <artifactId>sone</artifactId>
-       <version>0.6.1</version>
+       <version>0.6.7</version>
        <dependencies>
                <dependency>
                        <groupId>net.pterodactylus</groupId>
                        <artifactId>utils</artifactId>
-                       <version>0.9.4-SNAPSHOT</version>
 -                      <version>0.10.0</version>
++                      <version>0.10.1-SNAPSHOT</version>
                </dependency>
                <dependency>
                        <groupId>junit</groupId>
@@@ -25,21 -25,23 +25,26 @@@ import java.util.HashSet
  import java.util.List;
  import java.util.Map;
  import java.util.Set;
+ import java.util.Map.Entry;
+ import java.util.concurrent.ExecutorService;
+ import java.util.concurrent.Executors;
  import java.util.logging.Level;
  import java.util.logging.Logger;
  
  import net.pterodactylus.sone.core.Options.DefaultOption;
  import net.pterodactylus.sone.core.Options.Option;
  import net.pterodactylus.sone.core.Options.OptionWatcher;
 +import net.pterodactylus.sone.data.Album;
  import net.pterodactylus.sone.data.Client;
 +import net.pterodactylus.sone.data.Image;
  import net.pterodactylus.sone.data.Post;
  import net.pterodactylus.sone.data.Profile;
--import net.pterodactylus.sone.data.Profile.Field;
  import net.pterodactylus.sone.data.Reply;
  import net.pterodactylus.sone.data.Sone;
 +import net.pterodactylus.sone.data.TemporaryImage;
++import net.pterodactylus.sone.data.Profile.Field;
+ import net.pterodactylus.sone.fcp.FcpInterface;
+ import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired;
  import net.pterodactylus.sone.freenet.wot.Identity;
  import net.pterodactylus.sone.freenet.wot.IdentityListener;
  import net.pterodactylus.sone.freenet.wot.IdentityManager;
@@@ -51,6 -53,11 +56,11 @@@ import net.pterodactylus.util.config.Co
  import net.pterodactylus.util.config.ConfigurationException;
  import net.pterodactylus.util.logging.Logging;
  import net.pterodactylus.util.number.Numbers;
+ import net.pterodactylus.util.service.AbstractService;
+ import net.pterodactylus.util.thread.Ticker;
+ import net.pterodactylus.util.validation.EqualityValidator;
+ import net.pterodactylus.util.validation.IntegerRangeValidator;
+ import net.pterodactylus.util.validation.OrValidator;
  import net.pterodactylus.util.validation.Validation;
  import net.pterodactylus.util.version.Version;
  import freenet.keys.FreenetURI;
@@@ -60,7 -67,7 +70,7 @@@
   *
   * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
   */
- public class Core implements IdentityListener, UpdateListener, ImageInsertListener {
 -public class Core extends AbstractService implements IdentityListener, UpdateListener, SoneProvider, PostProvider, SoneInsertListener {
++public class Core extends AbstractService implements IdentityListener, UpdateListener, SoneProvider, PostProvider, SoneInsertListener, ImageInsertListener {
  
        /**
         * Enumeration for the possible states of a {@link Sone}.
        /** The Sone downloader. */
        private final SoneDownloader soneDownloader;
  
 +      /** The image inserter. */
 +      private final ImageInserter imageInserter;
 +
+       /** Sone downloader thread-pool. */
+       private final ExecutorService soneDownloaders = Executors.newFixedThreadPool(10);
        /** The update checker. */
        private final UpdateChecker updateChecker;
  
-       /** Whether the core has been stopped. */
-       private volatile boolean stopped;
+       /** The FCP interface. */
+       private volatile FcpInterface fcpInterface;
  
        /** The Sones’ statuses. */
        /* synchronize access on itself. */
        /* synchronize access on this on localSones. */
        private final Map<Sone, SoneInserter> soneInserters = new HashMap<Sone, SoneInserter>();
  
+       /** Sone rescuers. */
+       /* synchronize access on this on localSones. */
+       private final Map<Sone, SoneRescuer> soneRescuers = new HashMap<Sone, SoneRescuer>();
        /** All local Sones. */
        /* synchronize access on this on itself. */
        private Map<String, Sone> localSones = new HashMap<String, Sone>();
        /** Trusted identities, sorted by own identities. */
        private Map<OwnIdentity, Set<Identity>> trustedIdentities = Collections.synchronizedMap(new HashMap<OwnIdentity, Set<Identity>>());
  
 +      /** All known albums. */
 +      private Map<String, Album> albums = new HashMap<String, Album>();
 +
 +      /** All known images. */
 +      private Map<String, Image> images = new HashMap<String, Image>();
 +
 +      /** All temporary images. */
 +      private Map<String, TemporaryImage> temporaryImages = new HashMap<String, TemporaryImage>();
 +
+       /** Ticker for threads that mark own elements as known. */
+       private Ticker localElementTicker = new Ticker();
+       /** The time the configuration was last touched. */
+       private volatile long lastConfigurationUpdate;
        /**
         * Creates a new core.
         *
         *            The identity manager
         */
        public Core(Configuration configuration, FreenetInterface freenetInterface, IdentityManager identityManager) {
+               super("Sone Core");
                this.configuration = configuration;
                this.freenetInterface = freenetInterface;
                this.identityManager = identityManager;
                this.soneDownloader = new SoneDownloader(this, freenetInterface);
 +              this.imageInserter = new ImageInserter(this, freenetInterface);
                this.updateChecker = new UpdateChecker(freenetInterface);
        }
  
         */
        public void setConfiguration(Configuration configuration) {
                this.configuration = configuration;
-               saveConfiguration();
+               touchConfiguration();
        }
  
        /**
        }
  
        /**
+        * Sets the FCP interface to use.
+        *
+        * @param fcpInterface
+        *            The FCP interface to use
+        */
+       public void setFcpInterface(FcpInterface fcpInterface) {
+               this.fcpInterface = fcpInterface;
+       }
+       /**
         * Returns the status of the given Sone.
         *
         * @param sone
        }
  
        /**
+        * Returns the Sone rescuer for the given local Sone.
+        *
+        * @param sone
+        *            The local Sone to get the rescuer for
+        * @return The Sone rescuer for the given Sone
+        */
+       public SoneRescuer getSoneRescuer(Sone sone) {
+               Validation.begin().isNotNull("Sone", sone).check().is("Local Sone", isLocalSone(sone)).check();
+               synchronized (localSones) {
+                       SoneRescuer soneRescuer = soneRescuers.get(sone);
+                       if (soneRescuer == null) {
+                               soneRescuer = new SoneRescuer(this, soneDownloader, sone);
+                               soneRescuers.put(sone, soneRescuer);
+                               soneRescuer.start();
+                       }
+                       return soneRescuer;
+               }
+       }
+       /**
         * Returns whether the given Sone is currently locked.
         *
         * @param sone
         * @return The Sone with the given ID, or {@code null} if there is no such
         *         Sone
         */
+       @Override
        public Sone getSone(String id, boolean create) {
                if (isLocalSone(id)) {
                        return getLocalSone(id);
         *            exists, {@code false} to return {@code null}
         * @return The post, or {@code null} if there is no such post
         */
+       @Override
        public Post getPost(String postId, boolean create) {
                synchronized (posts) {
                        Post post = posts.get(postId);
         */
        public List<Reply> getReplies(Post post) {
                Set<Sone> sones = getSones();
+               @SuppressWarnings("hiding")
                List<Reply> replies = new ArrayList<Reply>();
                for (Sone sone : sones) {
                        for (Reply reply : sone.getReplies()) {
         * @return All bookmarked posts
         */
        public Set<Post> getBookmarkedPosts() {
+               @SuppressWarnings("hiding")
                Set<Post> posts = new HashSet<Post>();
                synchronized (bookmarkedPosts) {
                        for (String bookmarkedPostId : bookmarkedPosts) {
                return posts;
        }
  
 +      /**
 +       * Returns the album with the given ID, creating a new album if no album
 +       * with the given ID can be found.
 +       *
 +       * @param albumId
 +       *            The ID of the album
 +       * @return The album with the given ID
 +       */
 +      public Album getAlbum(String albumId) {
 +              return getAlbum(albumId, true);
 +      }
 +
 +      /**
 +       * Returns the album with the given ID, optionally creating a new album if
 +       * an album with the given ID can not be found.
 +       *
 +       * @param albumId
 +       *            The ID of the album
 +       * @param create
 +       *            {@code true} to create a new album if none exists for the
 +       *            given ID
 +       * @return The album with the given ID, or {@code null} if no album with the
 +       *         given ID exists and {@code create} is {@code false}
 +       */
 +      public Album getAlbum(String albumId, boolean create) {
 +              synchronized (albums) {
 +                      Album album = albums.get(albumId);
 +                      if (create && (album == null)) {
 +                              album = new Album(albumId);
 +                              albums.put(albumId, album);
 +                      }
 +                      return album;
 +              }
 +      }
 +
 +      /**
 +       * Returns the image with the given ID, creating it if necessary.
 +       *
 +       * @param imageId
 +       *            The ID of the image
 +       * @return The image with the given ID
 +       */
 +      public Image getImage(String imageId) {
 +              return getImage(imageId, true);
 +      }
 +
 +      /**
 +       * Returns the image with the given ID, optionally creating it if it does
 +       * not exist.
 +       *
 +       * @param imageId
 +       *            The ID of the image
 +       * @param create
 +       *            {@code true} to create an image if none exists with the given
 +       *            ID
 +       * @return The image with the given ID, or {@code null} if none exists and
 +       *         none was created
 +       */
 +      public Image getImage(String imageId, boolean create) {
 +              synchronized (images) {
 +                      Image image = images.get(imageId);
 +                      if (create && (image == null)) {
 +                              image = new Image(imageId);
 +                              images.put(imageId, image);
 +                      }
 +                      return image;
 +              }
 +      }
 +
 +      /**
 +       * Returns the temporary image with the given ID.
 +       *
 +       * @param imageId
 +       *            The ID of the temporary image
 +       * @return The temporary image, or {@code null} if there is no temporary
 +       *         image with the given ID
 +       */
 +      public TemporaryImage getTemporaryImage(String imageId) {
 +              synchronized (temporaryImages) {
 +                      return temporaryImages.get(imageId);
 +              }
 +      }
 +
        //
        // ACTIONS
        //
                        /* TODO - load posts ’n stuff */
                        localSones.put(ownIdentity.getId(), sone);
                        final SoneInserter soneInserter = new SoneInserter(this, freenetInterface, sone);
+                       soneInserter.addSoneInsertListener(this);
                        soneInserters.put(sone, soneInserter);
                        setSoneStatus(sone, SoneStatus.idle);
                        loadSone(sone);
-                       if (!preferences.isSoneRescueMode()) {
-                               soneInserter.start();
-                       }
-                       new Thread(new Runnable() {
-                               @Override
-                               @SuppressWarnings("synthetic-access")
-                               public void run() {
-                                       if (!preferences.isSoneRescueMode()) {
-                                               return;
-                                       }
-                                       logger.log(Level.INFO, "Trying to restore Sone from Freenet…");
-                                       coreListenerManager.fireRescuingSone(sone);
-                                       lockSone(sone);
-                                       long edition = sone.getLatestEdition();
-                                       while (!stopped && (edition >= 0) && preferences.isSoneRescueMode()) {
-                                               logger.log(Level.FINE, "Downloading edition " + edition + "…");
-                                               soneDownloader.fetchSone(sone, sone.getRequestUri().setKeyType("SSK").setDocName("Sone-" + edition));
-                                               --edition;
-                                       }
-                                       logger.log(Level.INFO, "Finished restoring Sone from Freenet, starting Inserter…");
-                                       saveSone(sone);
-                                       coreListenerManager.fireRescuedSone(sone);
-                                       soneInserter.start();
-                               }
-                       }, "Sone Downloader").start();
+                       soneInserter.start();
                        return sone;
                }
        }
                }
                Sone sone = addLocalSone(ownIdentity);
                sone.getOptions().addBooleanOption("AutoFollow", new DefaultOption<Boolean>(false));
-               saveSone(sone);
+               sone.addFriend("nwa8lHa271k2QvJ8aa0Ov7IHAV-DFOCFgmDt3X6BpCI");
+               touchConfiguration();
                return sone;
        }
  
                                        for (Sone localSone : getLocalSones()) {
                                                if (localSone.getOptions().getBooleanOption("AutoFollow").get()) {
                                                        localSone.addFriend(sone.getId());
+                                                       touchConfiguration();
                                                }
                                        }
                                }
                        remoteSones.put(identity.getId(), sone);
                        soneDownloader.addSone(sone);
                        setSoneStatus(sone, SoneStatus.unknown);
-                       new Thread(new Runnable() {
+                       soneDownloaders.execute(new Runnable() {
  
                                @Override
                                @SuppressWarnings("synthetic-access")
                                public void run() {
-                                       soneDownloader.fetchSone(sone);
+                                       soneDownloader.fetchSone(sone, sone.getRequestUri());
                                }
  
-                       }, "Sone Downloader").start();
+                       });
                        return sone;
                }
        }
        }
  
        /**
-        * Updates the stores Sone with the given Sone.
+        * Updates the stored Sone with the given Sone.
         *
         * @param sone
         *            The updated Sone
         */
        public void updateSone(Sone sone) {
+               updateSone(sone, false);
+       }
+       /**
+        * Updates the stored Sone with the given Sone. If {@code soneRescueMode} is
+        * {@code true}, an older Sone than the current Sone can be given to restore
+        * an old state.
+        *
+        * @param sone
+        *            The Sone to update
+        * @param soneRescueMode
+        *            {@code true} if the stored Sone should be updated regardless
+        *            of the age of the given Sone
+        */
+       public void updateSone(Sone sone, boolean soneRescueMode) {
                if (hasSone(sone.getId())) {
-                       boolean soneRescueMode = isLocalSone(sone) && preferences.isSoneRescueMode();
                        Sone storedSone = getSone(sone.getId());
                        if (!soneRescueMode && !(sone.getTime() > storedSone.getTime())) {
                                logger.log(Level.FINE, "Downloaded Sone %s is not newer than stored Sone %s.", new Object[] { sone, storedSone });
                                        storedSone.setReplies(sone.getReplies());
                                        storedSone.setLikePostIds(sone.getLikedPostIds());
                                        storedSone.setLikeReplyIds(sone.getLikedReplyIds());
 +                                      storedSone.setAlbums(sone.getAlbums());
                                }
                                storedSone.setLatestEdition(sone.getLatestEdition());
                        }
                                return;
                        }
                        localSones.remove(sone.getId());
-                       soneInserters.remove(sone).stop();
+                       SoneInserter soneInserter = soneInserters.remove(sone);
+                       soneInserter.removeSoneInsertListener(this);
+                       soneInserter.stop();
                }
                try {
                        ((OwnIdentity) sone.getIdentity()).removeContext("Sone");
                        if (newSones.remove(sone.getId())) {
                                knownSones.add(sone.getId());
                                coreListenerManager.fireMarkSoneKnown(sone);
-                               saveConfiguration();
+                               touchConfiguration();
                        }
                }
        }
                        return;
                }
  
+               /* initialize options. */
+               sone.getOptions().addBooleanOption("AutoFollow", new DefaultOption<Boolean>(false));
+               sone.getOptions().addBooleanOption("EnableSoneInsertNotifications", new DefaultOption<Boolean>(false));
                /* load Sone. */
                String sonePrefix = "Sone/" + sone.getId();
                Long soneTime = configuration.getLongValue(sonePrefix + "/Time").getValue(null);
                }
  
                /* load posts. */
+               @SuppressWarnings("hiding")
                Set<Post> posts = new HashSet<Post>();
                while (true) {
                        String postPrefix = sonePrefix + "/Posts/" + posts.size();
                }
  
                /* load replies. */
+               @SuppressWarnings("hiding")
                Set<Reply> replies = new HashSet<Reply>();
                while (true) {
                        String replyPrefix = sonePrefix + "/Replies/" + replies.size();
                        friends.add(friendId);
                }
  
 +              /* load albums. */
 +              List<Album> topLevelAlbums = new ArrayList<Album>();
 +              int albumCounter = 0;
 +              while (true) {
 +                      String albumPrefix = sonePrefix + "/Albums/" + albumCounter++;
 +                      String albumId = configuration.getStringValue(albumPrefix + "/ID").getValue(null);
 +                      if (albumId == null) {
 +                              break;
 +                      }
 +                      String albumTitle = configuration.getStringValue(albumPrefix + "/Title").getValue(null);
 +                      String albumDescription = configuration.getStringValue(albumPrefix + "/Description").getValue(null);
 +                      String albumParentId = configuration.getStringValue(albumPrefix + "/Parent").getValue(null);
 +                      if ((albumTitle == null) || (albumDescription == null)) {
 +                              logger.log(Level.WARNING, "Invalid album found, aborting load!");
 +                              return;
 +                      }
 +                      Album album = getAlbum(albumId).setSone(sone).setTitle(albumTitle).setDescription(albumDescription);
 +                      if (albumParentId != null) {
 +                              Album parentAlbum = getAlbum(albumParentId, false);
 +                              if (parentAlbum == null) {
 +                                      logger.log(Level.WARNING, "Invalid parent album ID: " + albumParentId);
 +                                      return;
 +                              }
 +                              parentAlbum.addAlbum(album);
 +                      } else {
 +                              topLevelAlbums.add(album);
 +                      }
 +              }
 +
 +              /* load images. */
 +              int imageCounter = 0;
 +              while (true) {
 +                      String imagePrefix = sonePrefix + "/Images/" + imageCounter++;
 +                      String imageId = configuration.getStringValue(imagePrefix + "/ID").getValue(null);
 +                      if (imageId == null) {
 +                              break;
 +                      }
 +                      String albumId = configuration.getStringValue(imagePrefix + "/Album").getValue(null);
 +                      String key = configuration.getStringValue(imagePrefix + "/Key").getValue(null);
 +                      String title = configuration.getStringValue(imagePrefix + "/Title").getValue(null);
 +                      String description = configuration.getStringValue(imagePrefix + "/Description").getValue(null);
 +                      Long creationTime = configuration.getLongValue(imagePrefix + "/CreationTime").getValue(null);
 +                      Integer width = configuration.getIntValue(imagePrefix + "/Width").getValue(null);
 +                      Integer height = configuration.getIntValue(imagePrefix + "/Height").getValue(null);
 +                      if ((albumId == null) || (key == null) || (title == null) || (description == null) || (creationTime == null) || (width == null) || (height == null)) {
 +                              logger.log(Level.WARNING, "Invalid image found, aborting load!");
 +                              return;
 +                      }
 +                      Album album = getAlbum(albumId, false);
 +                      if (album == null) {
 +                              logger.log(Level.WARNING, "Invalid album image encountered, aborting load!");
 +                              return;
 +                      }
 +                      Image image = getImage(imageId).setSone(sone).setCreationTime(creationTime).setKey(key);
 +                      image.setTitle(title).setDescription(description).setWidth(width).setHeight(height);
 +                      album.addImage(image);
 +              }
 +
                /* load options. */
-               sone.getOptions().addBooleanOption("AutoFollow", new DefaultOption<Boolean>(false));
                sone.getOptions().getBooleanOption("AutoFollow").set(configuration.getBooleanValue(sonePrefix + "/Options/AutoFollow").getValue(null));
+               sone.getOptions().getBooleanOption("EnableSoneInsertNotifications").set(configuration.getBooleanValue(sonePrefix + "/Options/EnableSoneInsertNotifications").getValue(null));
  
                /* if we’re still here, Sone was loaded successfully. */
                synchronized (sone) {
                        sone.setLikePostIds(likedPostIds);
                        sone.setLikeReplyIds(likedReplyIds);
                        sone.setFriends(friends);
 +                      sone.setAlbums(topLevelAlbums);
                        soneInserters.get(sone).setLastInsertFingerprint(lastInsertFingerprint);
                }
                synchronized (newSones) {
        }
  
        /**
-        * Saves the given Sone. This will persist all local settings for the given
-        * Sone, such as the friends list and similar, private options.
-        *
-        * @param sone
-        *            The Sone to save
-        */
-       public synchronized void saveSone(Sone sone) {
-               if (!isLocalSone(sone)) {
-                       logger.log(Level.FINE, "Tried to save non-local Sone: %s", sone);
-                       return;
-               }
-               if (!(sone.getIdentity() instanceof OwnIdentity)) {
-                       logger.log(Level.WARNING, "Local Sone without OwnIdentity found, refusing to save: %s", sone);
-                       return;
-               }
-               logger.log(Level.INFO, "Saving Sone: %s", sone);
-               try {
-                       ((OwnIdentity) sone.getIdentity()).setProperty("Sone.LatestEdition", String.valueOf(sone.getLatestEdition()));
-                       /* save Sone into configuration. */
-                       String sonePrefix = "Sone/" + sone.getId();
-                       configuration.getLongValue(sonePrefix + "/Time").setValue(sone.getTime());
-                       configuration.getStringValue(sonePrefix + "/LastInsertFingerprint").setValue(soneInserters.get(sone).getLastInsertFingerprint());
-                       /* save profile. */
-                       Profile profile = sone.getProfile();
-                       configuration.getStringValue(sonePrefix + "/Profile/FirstName").setValue(profile.getFirstName());
-                       configuration.getStringValue(sonePrefix + "/Profile/MiddleName").setValue(profile.getMiddleName());
-                       configuration.getStringValue(sonePrefix + "/Profile/LastName").setValue(profile.getLastName());
-                       configuration.getIntValue(sonePrefix + "/Profile/BirthDay").setValue(profile.getBirthDay());
-                       configuration.getIntValue(sonePrefix + "/Profile/BirthMonth").setValue(profile.getBirthMonth());
-                       configuration.getIntValue(sonePrefix + "/Profile/BirthYear").setValue(profile.getBirthYear());
-                       /* save profile fields. */
-                       int fieldCounter = 0;
-                       for (Field profileField : profile.getFields()) {
-                               String fieldPrefix = sonePrefix + "/Profile/Fields/" + fieldCounter++;
-                               configuration.getStringValue(fieldPrefix + "/Name").setValue(profileField.getName());
-                               configuration.getStringValue(fieldPrefix + "/Value").setValue(profileField.getValue());
-                       }
-                       configuration.getStringValue(sonePrefix + "/Profile/Fields/" + fieldCounter + "/Name").setValue(null);
-                       /* save posts. */
-                       int postCounter = 0;
-                       for (Post post : sone.getPosts()) {
-                               String postPrefix = sonePrefix + "/Posts/" + postCounter++;
-                               configuration.getStringValue(postPrefix + "/ID").setValue(post.getId());
-                               configuration.getStringValue(postPrefix + "/Recipient").setValue((post.getRecipient() != null) ? post.getRecipient().getId() : null);
-                               configuration.getLongValue(postPrefix + "/Time").setValue(post.getTime());
-                               configuration.getStringValue(postPrefix + "/Text").setValue(post.getText());
-                       }
-                       configuration.getStringValue(sonePrefix + "/Posts/" + postCounter + "/ID").setValue(null);
-                       /* save replies. */
-                       int replyCounter = 0;
-                       for (Reply reply : sone.getReplies()) {
-                               String replyPrefix = sonePrefix + "/Replies/" + replyCounter++;
-                               configuration.getStringValue(replyPrefix + "/ID").setValue(reply.getId());
-                               configuration.getStringValue(replyPrefix + "/Post/ID").setValue(reply.getPost().getId());
-                               configuration.getLongValue(replyPrefix + "/Time").setValue(reply.getTime());
-                               configuration.getStringValue(replyPrefix + "/Text").setValue(reply.getText());
-                       }
-                       configuration.getStringValue(sonePrefix + "/Replies/" + replyCounter + "/ID").setValue(null);
-                       /* save post likes. */
-                       int postLikeCounter = 0;
-                       for (String postId : sone.getLikedPostIds()) {
-                               configuration.getStringValue(sonePrefix + "/Likes/Post/" + postLikeCounter++ + "/ID").setValue(postId);
-                       }
-                       configuration.getStringValue(sonePrefix + "/Likes/Post/" + postLikeCounter + "/ID").setValue(null);
-                       /* save reply likes. */
-                       int replyLikeCounter = 0;
-                       for (String replyId : sone.getLikedReplyIds()) {
-                               configuration.getStringValue(sonePrefix + "/Likes/Reply/" + replyLikeCounter++ + "/ID").setValue(replyId);
-                       }
-                       configuration.getStringValue(sonePrefix + "/Likes/Reply/" + replyLikeCounter + "/ID").setValue(null);
-                       /* save friends. */
-                       int friendCounter = 0;
-                       for (String friendId : sone.getFriends()) {
-                               configuration.getStringValue(sonePrefix + "/Friends/" + friendCounter++ + "/ID").setValue(friendId);
-                       }
-                       configuration.getStringValue(sonePrefix + "/Friends/" + friendCounter + "/ID").setValue(null);
-                       /* save albums. first, collect in a flat structure, top-level first. */
-                       List<Album> albums = Sone.flattenAlbums(sone.getAlbums());
-                       int albumCounter = 0;
-                       for (Album album : albums) {
-                               String albumPrefix = sonePrefix + "/Albums/" + albumCounter++;
-                               configuration.getStringValue(albumPrefix + "/ID").setValue(album.getId());
-                               configuration.getStringValue(albumPrefix + "/Title").setValue(album.getTitle());
-                               configuration.getStringValue(albumPrefix + "/Description").setValue(album.getDescription());
-                               configuration.getStringValue(albumPrefix + "/Parent").setValue(album.getParent() == null ? null : album.getParent().getId());
-                       }
-                       configuration.getStringValue(sonePrefix + "/Albums/" + albumCounter + "/ID").setValue(null);
-                       /* save images. */
-                       int imageCounter = 0;
-                       for (Album album : albums) {
-                               for (Image image : album.getImages()) {
-                                       if (!image.isInserted()) {
-                                               continue;
-                                       }
-                                       String imagePrefix = sonePrefix + "/Images/" + imageCounter++;
-                                       configuration.getStringValue(imagePrefix + "/ID").setValue(image.getId());
-                                       configuration.getStringValue(imagePrefix + "/Album").setValue(album.getId());
-                                       configuration.getStringValue(imagePrefix + "/Key").setValue(image.getKey());
-                                       configuration.getStringValue(imagePrefix + "/Title").setValue(image.getTitle());
-                                       configuration.getStringValue(imagePrefix + "/Description").setValue(image.getDescription());
-                                       configuration.getLongValue(imagePrefix + "/CreationTime").setValue(image.getCreationTime());
-                                       configuration.getIntValue(imagePrefix + "/Width").setValue(image.getWidth());
-                                       configuration.getIntValue(imagePrefix + "/Height").setValue(image.getHeight());
-                               }
-                       }
-                       configuration.getStringValue(sonePrefix + "/Images/" + imageCounter + "/ID").setValue(null);
-                       /* save options. */
-                       configuration.getBooleanValue(sonePrefix + "/Options/AutoFollow").setValue(sone.getOptions().getBooleanOption("AutoFollow").getReal());
-                       configuration.save();
-                       logger.log(Level.INFO, "Sone %s saved.", sone);
-               } catch (ConfigurationException ce1) {
-                       logger.log(Level.WARNING, "Could not save Sone: " + sone, ce1);
-               } catch (WebOfTrustException wote1) {
-                       logger.log(Level.WARNING, "Could not set WoT property for Sone: " + sone, wote1);
-               }
-       }
-       /**
         * Creates a new post.
         *
         * @param sone
                        logger.log(Level.FINE, "Tried to create post for non-local Sone: %s", sone);
                        return null;
                }
-               Post post = new Post(sone, time, text);
+               final Post post = new Post(sone, time, text);
                if (recipient != null) {
                        post.setRecipient(recipient);
                }
                        coreListenerManager.fireNewPostFound(post);
                }
                sone.addPost(post);
-               saveSone(sone);
+               touchConfiguration();
+               localElementTicker.registerEvent(System.currentTimeMillis() + 10 * 1000, new Runnable() {
+                       /**
+                        * {@inheritDoc}
+                        */
+                       @Override
+                       public void run() {
+                               markPostKnown(post);
+                       }
+               }, "Mark " + post + " read.");
                return post;
        }
  
                synchronized (posts) {
                        posts.remove(post.getId());
                }
+               coreListenerManager.firePostRemoved(post);
                synchronized (newPosts) {
                        markPostKnown(post);
                        knownPosts.remove(post.getId());
                }
-               saveSone(post.getSone());
+               touchConfiguration();
        }
  
        /**
                        if (newPosts.remove(post.getId())) {
                                knownPosts.add(post.getId());
                                coreListenerManager.fireMarkPostKnown(post);
-                               saveConfiguration();
+                               touchConfiguration();
                        }
                }
        }
                        logger.log(Level.FINE, "Tried to create reply for non-local Sone: %s", sone);
                        return null;
                }
-               Reply reply = new Reply(sone, post, System.currentTimeMillis(), text);
+               final Reply reply = new Reply(sone, post, System.currentTimeMillis(), text);
                synchronized (replies) {
                        replies.put(reply.getId(), reply);
                }
                        coreListenerManager.fireNewReplyFound(reply);
                }
                sone.addReply(reply);
-               saveSone(sone);
+               touchConfiguration();
+               localElementTicker.registerEvent(System.currentTimeMillis() + 10 * 1000, new Runnable() {
+                       /**
+                        * {@inheritDoc}
+                        */
+                       @Override
+                       public void run() {
+                               markReplyKnown(reply);
+                       }
+               }, "Mark " + reply + " read.");
                return reply;
        }
  
                        knownReplies.remove(reply.getId());
                }
                sone.removeReply(reply);
-               saveSone(sone);
+               touchConfiguration();
        }
  
        /**
                        if (newReplies.remove(reply.getId())) {
                                knownReplies.add(reply.getId());
                                coreListenerManager.fireMarkReplyKnown(reply);
-                               saveConfiguration();
+                               touchConfiguration();
                        }
                }
        }
  
        /**
 +       * Creates a new top-level album for the given Sone.
 +       *
 +       * @param sone
 +       *            The Sone to create the album for
 +       * @return The new album
 +       */
 +      public Album createAlbum(Sone sone) {
 +              return createAlbum(sone, null);
 +      }
 +
 +      /**
 +       * Creates a new album for the given Sone.
 +       *
 +       * @param sone
 +       *            The Sone to create the album for
 +       * @param parent
 +       *            The parent of the album (may be {@code null} to create a
 +       *            top-level album)
 +       * @return The new album
 +       */
 +      public Album createAlbum(Sone sone, Album parent) {
 +              Album album = new Album();
 +              synchronized (albums) {
 +                      albums.put(album.getId(), album);
 +              }
 +              album.setSone(sone);
 +              if (parent != null) {
 +                      parent.addAlbum(album);
 +              } else {
 +                      sone.addAlbum(album);
 +              }
 +              return album;
 +      }
 +
 +      /**
 +       * Deletes the given album. The owner of the album has to be a local Sone,
 +       * and the album has to be {@link Album#isEmpty() empty} to be deleted.
 +       *
 +       * @param album
 +       *            The album to remove
 +       */
 +      public void deleteAlbum(Album album) {
 +              Validation.begin().isNotNull("Album", album).check().is("Local Sone", isLocalSone(album.getSone())).check();
 +              if (!album.isEmpty()) {
 +                      return;
 +              }
 +              if (album.getParent() == null) {
 +                      album.getSone().removeAlbum(album);
 +              } else {
 +                      album.getParent().removeAlbum(album);
 +              }
 +              synchronized (albums) {
 +                      albums.remove(album.getId());
 +              }
 +              saveSone(album.getSone());
 +      }
 +
 +      /**
 +       * Creates a new image.
 +       *
 +       * @param sone
 +       *            The Sone creating the image
 +       * @param album
 +       *            The album the image will be inserted into
 +       * @param temporaryImage
 +       *            The temporary image to create the image from
 +       * @return The newly created image
 +       */
 +      public Image createImage(Sone sone, Album album, TemporaryImage temporaryImage) {
 +              Validation.begin().isNotNull("Sone", sone).isNotNull("Album", album).isNotNull("Temporary Image", temporaryImage).check().is("Local Sone", isLocalSone(sone)).check().isEqual("Owner and Album Owner", sone, album.getSone()).check();
 +              Image image = new Image(temporaryImage.getId()).setSone(sone).setCreationTime(System.currentTimeMillis());
 +              album.addImage(image);
 +              synchronized (images) {
 +                      images.put(image.getId(), image);
 +              }
 +              imageInserter.insertImage(temporaryImage, image);
 +              return image;
 +      }
 +
 +      /**
 +       * Deletes the given image. This method will also delete a matching
 +       * temporary image.
 +       *
 +       * @see #deleteTemporaryImage(TemporaryImage)
 +       * @param image
 +       *            The image to delete
 +       */
 +      public void deleteImage(Image image) {
 +              Validation.begin().isNotNull("Image", image).check().is("Local Sone", isLocalSone(image.getSone())).check();
 +              deleteTemporaryImage(image.getId());
 +              image.getAlbum().removeImage(image);
 +              synchronized (images) {
 +                      images.remove(image.getId());
 +              }
 +              saveSone(image.getSone());
 +      }
 +
 +      /**
 +       * Creates a new temporary image.
 +       *
 +       * @param mimeType
 +       *            The MIME type of the temporary image
 +       * @param imageData
 +       *            The encoded data of the image
 +       * @return The temporary image
 +       */
 +      public TemporaryImage createTemporaryImage(String mimeType, byte[] imageData) {
 +              TemporaryImage temporaryImage = new TemporaryImage();
 +              temporaryImage.setMimeType(mimeType).setImageData(imageData);
 +              synchronized (temporaryImages) {
 +                      temporaryImages.put(temporaryImage.getId(), temporaryImage);
 +              }
 +              return temporaryImage;
 +      }
 +
 +      /**
 +       * Deletes the given temporary image.
 +       *
 +       * @param temporaryImage
 +       *            The temporary image to delete
 +       */
 +      public void deleteTemporaryImage(TemporaryImage temporaryImage) {
 +              Validation.begin().isNotNull("Temporary Image", temporaryImage).check();
 +              deleteTemporaryImage(temporaryImage.getId());
 +      }
 +
 +      /**
 +       * Deletes the temporary image with the given ID.
 +       *
 +       * @param imageId
 +       *            The ID of the temporary image to delete
 +       */
 +      public void deleteTemporaryImage(String imageId) {
 +              Validation.begin().isNotNull("Temporary Image ID", imageId).check();
 +              synchronized (temporaryImages) {
 +                      temporaryImages.remove(imageId);
 +              }
 +              Image image = getImage(imageId, false);
 +              if (image != null) {
 +                      imageInserter.cancelImageInsert(image);
 +              }
 +      }
 +
 +      /**
+        * Notifies the core that the configuration, either of the core or of a
+        * single local Sone, has changed, and that the configuration should be
+        * saved.
+        */
+       public void touchConfiguration() {
+               lastConfigurationUpdate = System.currentTimeMillis();
+       }
+       //
+       // SERVICE METHODS
+       //
+       /**
         * Starts the core.
         */
-       public void start() {
+       @Override
+       public void serviceStart() {
                loadConfiguration();
                updateChecker.addUpdateListener(this);
                updateChecker.start();
        }
  
        /**
+        * {@inheritDoc}
+        */
+       @Override
+       public void serviceRun() {
+               long lastSaved = System.currentTimeMillis();
+               while (!shouldStop()) {
+                       sleep(1000);
+                       long now = System.currentTimeMillis();
+                       if (shouldStop() || ((lastConfigurationUpdate > lastSaved) && ((now - lastConfigurationUpdate) > 5000))) {
+                               for (Sone localSone : getLocalSones()) {
+                                       saveSone(localSone);
+                               }
+                               saveConfiguration();
+                               lastSaved = now;
+                       }
+               }
+       }
+       /**
         * Stops the core.
         */
-       public void stop() {
+       @Override
+       public void serviceStop() {
                synchronized (localSones) {
                        for (SoneInserter soneInserter : soneInserters.values()) {
+                               soneInserter.removeSoneInsertListener(this);
                                soneInserter.stop();
                        }
                }
                updateChecker.stop();
                updateChecker.removeUpdateListener(this);
                soneDownloader.stop();
-               saveConfiguration();
-               stopped = true;
+       }
+       //
+       // PRIVATE METHODS
+       //
+       /**
+        * Saves the given Sone. This will persist all local settings for the given
+        * Sone, such as the friends list and similar, private options.
+        *
+        * @param sone
+        *            The Sone to save
+        */
+       private synchronized void saveSone(Sone sone) {
+               if (!isLocalSone(sone)) {
+                       logger.log(Level.FINE, "Tried to save non-local Sone: %s", sone);
+                       return;
+               }
+               if (!(sone.getIdentity() instanceof OwnIdentity)) {
+                       logger.log(Level.WARNING, "Local Sone without OwnIdentity found, refusing to save: %s", sone);
+                       return;
+               }
+               logger.log(Level.INFO, "Saving Sone: %s", sone);
+               try {
+                       ((OwnIdentity) sone.getIdentity()).setProperty("Sone.LatestEdition", String.valueOf(sone.getLatestEdition()));
+                       /* save Sone into configuration. */
+                       String sonePrefix = "Sone/" + sone.getId();
+                       configuration.getLongValue(sonePrefix + "/Time").setValue(sone.getTime());
+                       configuration.getStringValue(sonePrefix + "/LastInsertFingerprint").setValue(soneInserters.get(sone).getLastInsertFingerprint());
+                       /* save profile. */
+                       Profile profile = sone.getProfile();
+                       configuration.getStringValue(sonePrefix + "/Profile/FirstName").setValue(profile.getFirstName());
+                       configuration.getStringValue(sonePrefix + "/Profile/MiddleName").setValue(profile.getMiddleName());
+                       configuration.getStringValue(sonePrefix + "/Profile/LastName").setValue(profile.getLastName());
+                       configuration.getIntValue(sonePrefix + "/Profile/BirthDay").setValue(profile.getBirthDay());
+                       configuration.getIntValue(sonePrefix + "/Profile/BirthMonth").setValue(profile.getBirthMonth());
+                       configuration.getIntValue(sonePrefix + "/Profile/BirthYear").setValue(profile.getBirthYear());
+                       /* save profile fields. */
+                       int fieldCounter = 0;
+                       for (Field profileField : profile.getFields()) {
+                               String fieldPrefix = sonePrefix + "/Profile/Fields/" + fieldCounter++;
+                               configuration.getStringValue(fieldPrefix + "/Name").setValue(profileField.getName());
+                               configuration.getStringValue(fieldPrefix + "/Value").setValue(profileField.getValue());
+                       }
+                       configuration.getStringValue(sonePrefix + "/Profile/Fields/" + fieldCounter + "/Name").setValue(null);
+                       /* save posts. */
+                       int postCounter = 0;
+                       for (Post post : sone.getPosts()) {
+                               String postPrefix = sonePrefix + "/Posts/" + postCounter++;
+                               configuration.getStringValue(postPrefix + "/ID").setValue(post.getId());
+                               configuration.getStringValue(postPrefix + "/Recipient").setValue((post.getRecipient() != null) ? post.getRecipient().getId() : null);
+                               configuration.getLongValue(postPrefix + "/Time").setValue(post.getTime());
+                               configuration.getStringValue(postPrefix + "/Text").setValue(post.getText());
+                       }
+                       configuration.getStringValue(sonePrefix + "/Posts/" + postCounter + "/ID").setValue(null);
+                       /* save replies. */
+                       int replyCounter = 0;
+                       for (Reply reply : sone.getReplies()) {
+                               String replyPrefix = sonePrefix + "/Replies/" + replyCounter++;
+                               configuration.getStringValue(replyPrefix + "/ID").setValue(reply.getId());
+                               configuration.getStringValue(replyPrefix + "/Post/ID").setValue(reply.getPost().getId());
+                               configuration.getLongValue(replyPrefix + "/Time").setValue(reply.getTime());
+                               configuration.getStringValue(replyPrefix + "/Text").setValue(reply.getText());
+                       }
+                       configuration.getStringValue(sonePrefix + "/Replies/" + replyCounter + "/ID").setValue(null);
+                       /* save post likes. */
+                       int postLikeCounter = 0;
+                       for (String postId : sone.getLikedPostIds()) {
+                               configuration.getStringValue(sonePrefix + "/Likes/Post/" + postLikeCounter++ + "/ID").setValue(postId);
+                       }
+                       configuration.getStringValue(sonePrefix + "/Likes/Post/" + postLikeCounter + "/ID").setValue(null);
+                       /* save reply likes. */
+                       int replyLikeCounter = 0;
+                       for (String replyId : sone.getLikedReplyIds()) {
+                               configuration.getStringValue(sonePrefix + "/Likes/Reply/" + replyLikeCounter++ + "/ID").setValue(replyId);
+                       }
+                       configuration.getStringValue(sonePrefix + "/Likes/Reply/" + replyLikeCounter + "/ID").setValue(null);
+                       /* save friends. */
+                       int friendCounter = 0;
+                       for (String friendId : sone.getFriends()) {
+                               configuration.getStringValue(sonePrefix + "/Friends/" + friendCounter++ + "/ID").setValue(friendId);
+                       }
+                       configuration.getStringValue(sonePrefix + "/Friends/" + friendCounter + "/ID").setValue(null);
++                      /* save albums. first, collect in a flat structure, top-level first. */
++                      List<Album> albums = Sone.flattenAlbums(sone.getAlbums());
++
++                      int albumCounter = 0;
++                      for (Album album : albums) {
++                              String albumPrefix = sonePrefix + "/Albums/" + albumCounter++;
++                              configuration.getStringValue(albumPrefix + "/ID").setValue(album.getId());
++                              configuration.getStringValue(albumPrefix + "/Title").setValue(album.getTitle());
++                              configuration.getStringValue(albumPrefix + "/Description").setValue(album.getDescription());
++                              configuration.getStringValue(albumPrefix + "/Parent").setValue(album.getParent() == null ? null : album.getParent().getId());
++                      }
++                      configuration.getStringValue(sonePrefix + "/Albums/" + albumCounter + "/ID").setValue(null);
++
++                      /* save images. */
++                      int imageCounter = 0;
++                      for (Album album : albums) {
++                              for (Image image : album.getImages()) {
++                                      if (!image.isInserted()) {
++                                              continue;
++                                      }
++                                      String imagePrefix = sonePrefix + "/Images/" + imageCounter++;
++                                      configuration.getStringValue(imagePrefix + "/ID").setValue(image.getId());
++                                      configuration.getStringValue(imagePrefix + "/Album").setValue(album.getId());
++                                      configuration.getStringValue(imagePrefix + "/Key").setValue(image.getKey());
++                                      configuration.getStringValue(imagePrefix + "/Title").setValue(image.getTitle());
++                                      configuration.getStringValue(imagePrefix + "/Description").setValue(image.getDescription());
++                                      configuration.getLongValue(imagePrefix + "/CreationTime").setValue(image.getCreationTime());
++                                      configuration.getIntValue(imagePrefix + "/Width").setValue(image.getWidth());
++                                      configuration.getIntValue(imagePrefix + "/Height").setValue(image.getHeight());
++                              }
++                      }
++                      configuration.getStringValue(sonePrefix + "/Images/" + imageCounter + "/ID").setValue(null);
++
+                       /* save options. */
+                       configuration.getBooleanValue(sonePrefix + "/Options/AutoFollow").setValue(sone.getOptions().getBooleanOption("AutoFollow").getReal());
+                       configuration.getBooleanValue(sonePrefix + "/Options/EnableSoneInsertNotifications").setValue(sone.getOptions().getBooleanOption("EnableSoneInsertNotifications").getReal());
+                       configuration.save();
+                       logger.log(Level.INFO, "Sone %s saved.", sone);
+               } catch (ConfigurationException ce1) {
+                       logger.log(Level.WARNING, "Could not save Sone: " + sone, ce1);
+               } catch (WebOfTrustException wote1) {
+                       logger.log(Level.WARNING, "Could not set WoT property for Sone: " + sone, wote1);
+               }
        }
  
        /**
         * Saves the current options.
         */
-       public void saveConfiguration() {
+       private void saveConfiguration() {
                synchronized (configuration) {
                        if (storingConfiguration) {
                                logger.log(Level.FINE, "Already storing configuration…");
                        configuration.getIntValue("Option/ConfigurationVersion").setValue(0);
                        configuration.getIntValue("Option/InsertionDelay").setValue(options.getIntegerOption("InsertionDelay").getReal());
                        configuration.getIntValue("Option/PostsPerPage").setValue(options.getIntegerOption("PostsPerPage").getReal());
+                       configuration.getIntValue("Option/CharactersPerPost").setValue(options.getIntegerOption("CharactersPerPost").getReal());
+                       configuration.getBooleanValue("Option/RequireFullAccess").setValue(options.getBooleanOption("RequireFullAccess").getReal());
                        configuration.getIntValue("Option/PositiveTrust").setValue(options.getIntegerOption("PositiveTrust").getReal());
                        configuration.getIntValue("Option/NegativeTrust").setValue(options.getIntegerOption("NegativeTrust").getReal());
                        configuration.getStringValue("Option/TrustComment").setValue(options.getStringOption("TrustComment").getReal());
+                       configuration.getBooleanValue("Option/ActivateFcpInterface").setValue(options.getBooleanOption("ActivateFcpInterface").getReal());
+                       configuration.getIntValue("Option/FcpFullAccessRequired").setValue(options.getIntegerOption("FcpFullAccessRequired").getReal());
                        configuration.getBooleanValue("Option/SoneRescueMode").setValue(options.getBooleanOption("SoneRescueMode").getReal());
                        configuration.getBooleanValue("Option/ClearOnNextRestart").setValue(options.getBooleanOption("ClearOnNextRestart").getReal());
                        configuration.getBooleanValue("Option/ReallyClearOnNextRestart").setValue(options.getBooleanOption("ReallyClearOnNextRestart").getReal());
                }
        }
  
-       //
-       // PRIVATE METHODS
-       //
        /**
         * Loads the configuration.
         */
        @SuppressWarnings("unchecked")
        private void loadConfiguration() {
                /* create options. */
-               options.addIntegerOption("InsertionDelay", new DefaultOption<Integer>(60, new OptionWatcher<Integer>() {
+               options.addIntegerOption("InsertionDelay", new DefaultOption<Integer>(60, new IntegerRangeValidator(0, Integer.MAX_VALUE), new OptionWatcher<Integer>() {
  
                        @Override
                        public void optionChanged(Option<Integer> option, Integer oldValue, Integer newValue) {
                        }
  
                }));
-               options.addIntegerOption("PostsPerPage", new DefaultOption<Integer>(10));
-               options.addIntegerOption("PositiveTrust", new DefaultOption<Integer>(75));
-               options.addIntegerOption("NegativeTrust", new DefaultOption<Integer>(-25));
+               options.addIntegerOption("PostsPerPage", new DefaultOption<Integer>(10, new IntegerRangeValidator(1, Integer.MAX_VALUE)));
+               options.addIntegerOption("CharactersPerPost", new DefaultOption<Integer>(200, new OrValidator<Integer>(new IntegerRangeValidator(50, Integer.MAX_VALUE), new EqualityValidator<Integer>(-1))));
+               options.addBooleanOption("RequireFullAccess", new DefaultOption<Boolean>(false));
+               options.addIntegerOption("PositiveTrust", new DefaultOption<Integer>(75, new IntegerRangeValidator(0, 100)));
+               options.addIntegerOption("NegativeTrust", new DefaultOption<Integer>(-25, new IntegerRangeValidator(-100, 100)));
                options.addStringOption("TrustComment", new DefaultOption<String>("Set from Sone Web Interface"));
+               options.addBooleanOption("ActivateFcpInterface", new DefaultOption<Boolean>(false, new OptionWatcher<Boolean>() {
+                       @Override
+                       @SuppressWarnings("synthetic-access")
+                       public void optionChanged(Option<Boolean> option, Boolean oldValue, Boolean newValue) {
+                               fcpInterface.setActive(newValue);
+                       }
+               }));
+               options.addIntegerOption("FcpFullAccessRequired", new DefaultOption<Integer>(2, new OptionWatcher<Integer>() {
+                       @Override
+                       @SuppressWarnings("synthetic-access")
+                       public void optionChanged(Option<Integer> option, Integer oldValue, Integer newValue) {
+                               fcpInterface.setFullAccessRequired(FullAccessRequired.values()[newValue]);
+                       }
+               }));
                options.addBooleanOption("SoneRescueMode", new DefaultOption<Boolean>(false));
                options.addBooleanOption("ClearOnNextRestart", new DefaultOption<Boolean>(false));
                options.addBooleanOption("ReallyClearOnNextRestart", new DefaultOption<Boolean>(false));
                        return;
                }
  
-               options.getIntegerOption("InsertionDelay").set(configuration.getIntValue("Option/InsertionDelay").getValue(null));
-               options.getIntegerOption("PostsPerPage").set(configuration.getIntValue("Option/PostsPerPage").getValue(null));
-               options.getIntegerOption("PositiveTrust").set(configuration.getIntValue("Option/PositiveTrust").getValue(null));
-               options.getIntegerOption("NegativeTrust").set(configuration.getIntValue("Option/NegativeTrust").getValue(null));
+               loadConfigurationValue("InsertionDelay");
+               loadConfigurationValue("PostsPerPage");
+               loadConfigurationValue("CharactersPerPost");
+               options.getBooleanOption("RequireFullAccess").set(configuration.getBooleanValue("Option/RequireFullAccess").getValue(null));
+               loadConfigurationValue("PositiveTrust");
+               loadConfigurationValue("NegativeTrust");
                options.getStringOption("TrustComment").set(configuration.getStringValue("Option/TrustComment").getValue(null));
+               options.getBooleanOption("ActivateFcpInterface").set(configuration.getBooleanValue("Option/ActivateFcpInterface").getValue(null));
+               options.getIntegerOption("FcpFullAccessRequired").set(configuration.getIntValue("Option/FcpFullAccessRequired").getValue(null));
                options.getBooleanOption("SoneRescueMode").set(configuration.getBooleanValue("Option/SoneRescueMode").getValue(null));
  
                /* load known Sones. */
        }
  
        /**
+        * Loads an {@link Integer} configuration value for the option with the
+        * given name, logging validation failures.
+        *
+        * @param optionName
+        *            The name of the option to load
+        */
+       private void loadConfigurationValue(String optionName) {
+               try {
+                       options.getIntegerOption(optionName).set(configuration.getIntValue("Option/" + optionName).getValue(null));
+               } catch (IllegalArgumentException iae1) {
+                       logger.log(Level.WARNING, "Invalid value for " + optionName + " in configuration, using default.");
+               }
+       }
+       /**
         * Generate a Sone URI from the given URI and latest edition.
         *
         * @param uriString
                        public void run() {
                                Sone sone = getRemoteSone(identity.getId());
                                sone.setIdentity(identity);
+                               sone.setLatestEdition(Numbers.safeParseLong(identity.getProperty("Sone.LatestEdition"), sone.getLatestEdition()));
                                soneDownloader.addSone(sone);
                                soneDownloader.fetchSone(sone);
                        }
        @Override
        public void identityRemoved(OwnIdentity ownIdentity, Identity identity) {
                trustedIdentities.get(ownIdentity).remove(identity);
+               boolean foundIdentity = false;
+               for (Entry<OwnIdentity, Set<Identity>> trustedIdentity : trustedIdentities.entrySet()) {
+                       if (trustedIdentity.getKey().equals(ownIdentity)) {
+                               continue;
+                       }
+                       if (trustedIdentity.getValue().contains(identity)) {
+                               foundIdentity = true;
+                       }
+               }
+               if (foundIdentity) {
+                       /* some local identity still trusts this identity, don’t remove. */
+                       return;
+               }
+               Sone sone = getSone(identity.getId(), false);
+               if (sone == null) {
+                       /* TODO - we don’t have the Sone anymore. should this happen? */
+                       return;
+               }
+               synchronized (posts) {
+                       synchronized (newPosts) {
+                               for (Post post : sone.getPosts()) {
+                                       posts.remove(post.getId());
+                                       newPosts.remove(post.getId());
+                                       coreListenerManager.firePostRemoved(post);
+                               }
+                       }
+               }
+               synchronized (replies) {
+                       synchronized (newReplies) {
+                               for (Reply reply : sone.getReplies()) {
+                                       replies.remove(reply.getId());
+                                       newReplies.remove(reply.getId());
+                                       coreListenerManager.fireReplyRemoved(reply);
+                               }
+                       }
+               }
+               synchronized (remoteSones) {
+                       remoteSones.remove(identity.getId());
+               }
+               synchronized (newSones) {
+                       newSones.remove(identity.getId());
+                       coreListenerManager.fireSoneRemoved(sone);
+               }
        }
  
        //
        }
  
        //
 -      // SONEINSERTLISTENER METHODS
 +      // INTERFACE ImageInsertListener
        //
  
        /**
         * {@inheritDoc}
         */
 +      @Override
+       public void insertStarted(Sone sone) {
+               coreListenerManager.fireSoneInserting(sone);
+       }
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public void insertFinished(Sone sone, long insertDuration) {
+               coreListenerManager.fireSoneInserted(sone, insertDuration);
+       }
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public void insertAborted(Sone sone, Throwable cause) {
+               coreListenerManager.fireSoneInsertAborted(sone, cause);
+       }
++      //
++      // SONEINSERTLISTENER METHODS
++      //
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
 +      public void imageInsertStarted(Image image) {
 +              logger.log(Level.WARNING, "Image insert started for " + image);
 +              coreListenerManager.fireImageInsertStarted(image);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public void imageInsertAborted(Image image) {
 +              logger.log(Level.WARNING, "Image insert aborted for " + image);
 +              coreListenerManager.fireImageInsertAborted(image);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public void imageInsertFinished(Image image, FreenetURI key) {
 +              logger.log(Level.WARNING, "Image insert finished for " + image + ": " + key);
 +              image.setKey(key.toString());
 +              deleteTemporaryImage(image.getId());
 +              saveSone(image.getSone());
 +              coreListenerManager.fireImageInsertFinished(image);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public void imageInsertFailed(Image image, Throwable cause) {
 +              logger.log(Level.WARNING, "Image insert failed for " + image, cause);
 +              coreListenerManager.fireImageInsertFailed(image, cause);
 +      }
 +
        /**
         * Convenience interface for external classes that want to access the core’s
         * configuration.
                }
  
                /**
+                * Validates the given insertion delay.
+                *
+                * @param insertionDelay
+                *            The insertion delay to validate
+                * @return {@code true} if the given insertion delay was valid, {@code
+                *         false} otherwise
+                */
+               public boolean validateInsertionDelay(Integer insertionDelay) {
+                       return options.getIntegerOption("InsertionDelay").validate(insertionDelay);
+               }
+               /**
                 * Sets the insertion delay
                 *
                 * @param insertionDelay
                }
  
                /**
+                * Validates the number of posts per page.
+                *
+                * @param postsPerPage
+                *            The number of posts per page
+                * @return {@code true} if the number of posts per page was valid,
+                *         {@code false} otherwise
+                */
+               public boolean validatePostsPerPage(Integer postsPerPage) {
+                       return options.getIntegerOption("PostsPerPage").validate(postsPerPage);
+               }
+               /**
                 * Sets the number of posts to show per page.
                 *
                 * @param postsPerPage
                }
  
                /**
+                * Returns the number of characters per post, or <code>-1</code> if the
+                * posts should not be cut off.
+                *
+                * @return The numbers of characters per post
+                */
+               public int getCharactersPerPost() {
+                       return options.getIntegerOption("CharactersPerPost").get();
+               }
+               /**
+                * Validates the number of characters per post.
+                *
+                * @param charactersPerPost
+                *            The number of characters per post
+                * @return {@code true} if the number of characters per post was valid,
+                *         {@code false} otherwise
+                */
+               public boolean validateCharactersPerPost(Integer charactersPerPost) {
+                       return options.getIntegerOption("CharactersPerPost").validate(charactersPerPost);
+               }
+               /**
+                * Sets the number of characters per post.
+                *
+                * @param charactersPerPost
+                *            The number of characters per post, or <code>-1</code> to
+                *            not cut off the posts
+                * @return This preferences objects
+                */
+               public Preferences setCharactersPerPost(Integer charactersPerPost) {
+                       options.getIntegerOption("CharactersPerPost").set(charactersPerPost);
+                       return this;
+               }
+               /**
+                * Returns whether Sone requires full access to be even visible.
+                *
+                * @return {@code true} if Sone requires full access, {@code false}
+                *         otherwise
+                */
+               public boolean isRequireFullAccess() {
+                       return options.getBooleanOption("RequireFullAccess").get();
+               }
+               /**
+                * Sets whether Sone requires full access to be even visible.
+                *
+                * @param requireFullAccess
+                *            {@code true} if Sone requires full access, {@code false}
+                *            otherwise
+                */
+               public void setRequireFullAccess(Boolean requireFullAccess) {
+                       options.getBooleanOption("RequireFullAccess").set(requireFullAccess);
+               }
+               /**
                 * Returns the positive trust.
                 *
                 * @return The positive trust
                }
  
                /**
+                * Validates the positive trust.
+                *
+                * @param positiveTrust
+                *            The positive trust to validate
+                * @return {@code true} if the positive trust was valid, {@code false}
+                *         otherwise
+                */
+               public boolean validatePositiveTrust(Integer positiveTrust) {
+                       return options.getIntegerOption("PositiveTrust").validate(positiveTrust);
+               }
+               /**
                 * Sets the positive trust.
                 *
                 * @param positiveTrust
                }
  
                /**
+                * Validates the negative trust.
+                *
+                * @param negativeTrust
+                *            The negative trust to validate
+                * @return {@code true} if the negative trust was valid, {@code false}
+                *         otherwise
+                */
+               public boolean validateNegativeTrust(Integer negativeTrust) {
+                       return options.getIntegerOption("NegativeTrust").validate(negativeTrust);
+               }
+               /**
                 * Sets the negative trust.
                 *
                 * @param negativeTrust
                }
  
                /**
-                * Returns whether the rescue mode is active.
+                * Returns whether the {@link FcpInterface FCP interface} is currently
+                * active.
                 *
-                * @return {@code true} if the rescue mode is active, {@code false}
-                *         otherwise
+                * @see FcpInterface#setActive(boolean)
+                * @return {@code true} if the FCP interface is currently active,
+                *         {@code false} otherwise
                 */
-               public boolean isSoneRescueMode() {
-                       return options.getBooleanOption("SoneRescueMode").get();
+               public boolean isFcpInterfaceActive() {
+                       return options.getBooleanOption("ActivateFcpInterface").get();
                }
  
                /**
-                * Sets whether the rescue mode is active.
+                * Sets whether the {@link FcpInterface FCP interface} is currently
+                * active.
                 *
-                * @param soneRescueMode
-                *            {@code true} if the rescue mode is active, {@code false}
-                *            otherwise
+                * @see FcpInterface#setActive(boolean)
+                * @param fcpInterfaceActive
+                *            {@code true} to activate the FCP interface, {@code false}
+                *            to deactivate the FCP interface
+                * @return This preferences object
+                */
+               public Preferences setFcpInterfaceActive(boolean fcpInterfaceActive) {
+                       options.getBooleanOption("ActivateFcpInterface").set(fcpInterfaceActive);
+                       return this;
+               }
+               /**
+                * Returns the action level for which full access to the FCP interface
+                * is required.
+                *
+                * @return The action level for which full access to the FCP interface
+                *         is required
+                */
+               public FullAccessRequired getFcpFullAccessRequired() {
+                       return FullAccessRequired.values()[options.getIntegerOption("FcpFullAccessRequired").get()];
+               }
+               /**
+                * Sets the action level for which full access to the FCP interface is
+                * required
+                *
+                * @param fcpFullAccessRequired
+                *            The action level
                 * @return This preferences
                 */
-               public Preferences setSoneRescueMode(Boolean soneRescueMode) {
-                       options.getBooleanOption("SoneRescueMode").set(soneRescueMode);
+               public Preferences setFcpFullAccessRequired(FullAccessRequired fcpFullAccessRequired) {
+                       options.getIntegerOption("FcpFullAccessRequired").set((fcpFullAccessRequired != null) ? fcpFullAccessRequired.ordinal() : null);
                        return this;
                }
  
@@@ -19,7 -19,6 +19,7 @@@ package net.pterodactylus.sone.core
  
  import java.util.EventListener;
  
 +import net.pterodactylus.sone.data.Image;
  import net.pterodactylus.sone.data.Post;
  import net.pterodactylus.sone.data.Reply;
  import net.pterodactylus.sone.data.Sone;
@@@ -34,22 -33,6 +34,6 @@@ import net.pterodactylus.util.version.V
  public interface CoreListener extends EventListener {
  
        /**
-        * Notifies a listener that a Sone is now being rescued.
-        *
-        * @param sone
-        *            The Sone that is rescued
-        */
-       public void rescuingSone(Sone sone);
-       /**
-        * Notifies a listener that the Sone was rescued and can now be unlocked.
-        *
-        * @param sone
-        *            The Sone that was rescued
-        */
-       public void rescuedSone(Sone sone);
-       /**
         * Notifies a listener that a new Sone has been discovered.
         *
         * @param sone
        public void markReplyKnown(Reply reply);
  
        /**
+        * Notifies a listener that the given Sone was removed.
+        *
+        * @param sone
+        *            The removed Sone
+        */
+       public void soneRemoved(Sone sone);
+       /**
         * Notifies a listener that the given post was removed.
         *
         * @param post
        public void soneUnlocked(Sone sone);
  
        /**
+        * Notifies a listener that the insert of the given Sone has started.
+        *
+        * @see SoneInsertListener#insertStarted(Sone)
+        * @param sone
+        *            The Sone that is being inserted
+        */
+       public void soneInserting(Sone sone);
+       /**
+        * Notifies a listener that the insert of the given Sone has finished
+        * successfully.
+        *
+        * @see SoneInsertListener#insertFinished(Sone, long)
+        * @param sone
+        *            The Sone that has been inserted
+        * @param insertDuration
+        *            The insert duration (in milliseconds)
+        */
+       public void soneInserted(Sone sone, long insertDuration);
+       /**
+        * Notifies a listener that the insert of the given Sone was aborted.
+        *
+        * @see SoneInsertListener#insertAborted(Sone, Throwable)
+        * @param sone
+        *            The Sone that was inserted
+        * @param cause
+        *            The cause for the abortion (may be {@code null})
+        */
+       public void soneInsertAborted(Sone sone, Throwable cause);
+       /**
         * Notifies a listener that a new version has been found.
         *
         * @param version
         */
        public void updateFound(Version version, long releaseTime, long latestEdition);
  
 +      /**
 +       * Notifies a listener that an image has started being inserted.
 +       *
 +       * @param image
 +       *            The image that is now inserted
 +       */
 +      public void imageInsertStarted(Image image);
 +
 +      /**
 +       * Notifies a listener that an image insert was aborted by the user.
 +       *
 +       * @param image
 +       *            The image that is not inserted anymore
 +       */
 +      public void imageInsertAborted(Image image);
 +
 +      /**
 +       * Notifies a listener that an image was successfully inserted.
 +       *
 +       * @param image
 +       *            The image that was inserted
 +       */
 +      public void imageInsertFinished(Image image);
 +
 +      /**
 +       * Notifies a listener that an image failed to be inserted.
 +       *
 +       * @param image
 +       *            The image that could not be inserted
 +       * @param cause
 +       *            The reason for the failed insert
 +       */
 +      public void imageInsertFailed(Image image, Throwable cause);
 +
  }
@@@ -17,7 -17,6 +17,7 @@@
  
  package net.pterodactylus.sone.core;
  
 +import net.pterodactylus.sone.data.Image;
  import net.pterodactylus.sone.data.Post;
  import net.pterodactylus.sone.data.Reply;
  import net.pterodactylus.sone.data.Sone;
@@@ -46,32 -45,6 +46,6 @@@ public class CoreListenerManager extend
        //
  
        /**
-        * Notifies all listeners that the given Sone is now being rescued.
-        *
-        * @see CoreListener#rescuingSone(Sone)
-        * @param sone
-        *            The Sone that is being rescued
-        */
-       void fireRescuingSone(Sone sone) {
-               for (CoreListener coreListener : getListeners()) {
-                       coreListener.rescuingSone(sone);
-               }
-       }
-       /**
-        * Notifies all listeners that the given Sone was rescued.
-        *
-        * @see CoreListener#rescuedSone(Sone)
-        * @param sone
-        *            The Sone that was rescued
-        */
-       void fireRescuedSone(Sone sone) {
-               for (CoreListener coreListener : getListeners()) {
-                       coreListener.rescuedSone(sone);
-               }
-       }
-       /**
         * Notifies all listeners that a new Sone has been discovered.
         *
         * @see CoreListener#newSoneFound(Sone)
        }
  
        /**
+        * Notifies all listener that the given Sone was removed.
+        *
+        * @see CoreListener#soneRemoved(Sone)
+        * @param sone
+        *            The removed Sone
+        */
+       void fireSoneRemoved(Sone sone) {
+               for (CoreListener coreListener : getListeners()) {
+                       coreListener.soneRemoved(sone);
+               }
+       }
+       /**
         * Notifies all listener that the given post was removed.
         *
         * @see CoreListener#postRemoved(Post)
        }
  
        /**
+        * Notifies all listeners that the insert of the given Sone has started.
+        *
+        * @see SoneInsertListener#insertStarted(Sone)
+        * @param sone
+        *            The Sone being inserted
+        */
+       void fireSoneInserting(Sone sone) {
+               for (CoreListener coreListener : getListeners()) {
+                       coreListener.soneInserting(sone);
+               }
+       }
+       /**
+        * Notifies all listeners that the insert of the given Sone has finished
+        * successfully.
+        *
+        * @see SoneInsertListener#insertFinished(Sone, long)
+        * @param sone
+        *            The Sone that was inserted
+        * @param insertDuration
+        *            The insert duration (in milliseconds)
+        */
+       void fireSoneInserted(Sone sone, long insertDuration) {
+               for (CoreListener coreListener : getListeners()) {
+                       coreListener.soneInserted(sone, insertDuration);
+               }
+       }
+       /**
+        * Notifies all listeners that the insert of the given Sone was aborted.
+        *
+        * @see SoneInsertListener#insertStarted(Sone)
+        * @param sone
+        *            The Sone being inserted
+        * @param cause
+        *            The cause for the abortion (may be {@code null}
+        */
+       void fireSoneInsertAborted(Sone sone, Throwable cause) {
+               for (CoreListener coreListener : getListeners()) {
+                       coreListener.soneInsertAborted(sone, cause);
+               }
+       }
+       /**
         * Notifies all listeners that a new version was found.
         *
         * @see CoreListener#updateFound(Version, long, long)
                }
        }
  
 +      /**
 +       * Notifies all listeners that an image has started being inserted.
 +       *
 +       * @see CoreListener#imageInsertStarted(Image)
 +       * @param image
 +       *            The image that is now inserted
 +       */
 +      void fireImageInsertStarted(Image image) {
 +              for (CoreListener coreListener : getListeners()) {
 +                      coreListener.imageInsertStarted(image);
 +              }
 +      }
 +
 +      /**
 +       * Notifies all listeners that an image insert was aborted by the user.
 +       *
 +       * @see CoreListener#imageInsertAborted(Image)
 +       * @param image
 +       *            The image that is not inserted anymore
 +       */
 +      void fireImageInsertAborted(Image image) {
 +              for (CoreListener coreListener : getListeners()) {
 +                      coreListener.imageInsertAborted(image);
 +              }
 +      }
 +
 +      /**
 +       * Notifies all listeners that an image was successfully inserted.
 +       *
 +       * @see CoreListener#imageInsertFinished(Image)
 +       * @param image
 +       *            The image that was inserted
 +       */
 +      void fireImageInsertFinished(Image image) {
 +              for (CoreListener coreListener : getListeners()) {
 +                      coreListener.imageInsertFinished(image);
 +              }
 +      }
 +
 +      /**
 +       * Notifies all listeners that an image failed to be inserted.
 +       *
 +       * @see CoreListener#imageInsertFailed(Image, Throwable)
 +       * @param image
 +       *            The image that could not be inserted
 +       * @param cause
 +       *            The cause of the failure
 +       */
 +      void fireImageInsertFailed(Image image, Throwable cause) {
 +              for (CoreListener coreListener : getListeners()) {
 +                      coreListener.imageInsertFailed(image, cause);
 +              }
 +      }
 +
  }
@@@ -1,5 -1,5 +1,5 @@@
  /*
-  * FreenetSone - FreenetInterface.java - Copyright © 2010 David Roden
+  * Sone - FreenetInterface.java - Copyright © 2010 David Roden
   *
   * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
  package net.pterodactylus.sone.core;
  
  import java.net.MalformedURLException;
 +import java.util.ArrayList;
  import java.util.Collections;
  import java.util.HashMap;
 +import java.util.List;
  import java.util.Map;
  import java.util.logging.Level;
  import java.util.logging.Logger;
  
 +import net.pterodactylus.sone.core.SoneException.Type;
 +import net.pterodactylus.sone.data.Image;
  import net.pterodactylus.sone.data.Sone;
 +import net.pterodactylus.sone.data.TemporaryImage;
  import net.pterodactylus.util.collection.Pair;
  import net.pterodactylus.util.logging.Logging;
  
  import com.db4o.ObjectContainer;
  
 +import freenet.client.ClientMetadata;
  import freenet.client.FetchException;
  import freenet.client.FetchResult;
  import freenet.client.HighLevelSimpleClient;
  import freenet.client.HighLevelSimpleClientImpl;
 +import freenet.client.InsertBlock;
 +import freenet.client.InsertContext;
  import freenet.client.InsertException;
 +import freenet.client.async.BaseClientPutter;
  import freenet.client.async.ClientContext;
 +import freenet.client.async.ClientPutCallback;
 +import freenet.client.async.ClientPutter;
  import freenet.client.async.USKCallback;
  import freenet.keys.FreenetURI;
 +import freenet.keys.InsertableClientSSK;
  import freenet.keys.USK;
  import freenet.node.Node;
  import freenet.node.RequestStarter;
 +import freenet.support.api.Bucket;
 +import freenet.support.io.ArrayBucket;
  
  /**
   * Contains all necessary functionality for interacting with the Freenet node.
@@@ -129,36 -115,6 +129,36 @@@ public class FreenetInterface 
        }
  
        /**
 +       * Inserts the image data of the given {@link TemporaryImage} and returns
 +       * the given insert token that can be used to add listeners or cancel the
 +       * insert.
 +       *
 +       * @param temporaryImage
 +       *            The temporary image data
 +       * @param image
 +       *            The image
 +       * @param insertToken
 +       *            The insert token
 +       * @throws SoneException
 +       *             if the insert could not be started
 +       */
 +      public void insertImage(TemporaryImage temporaryImage, Image image, InsertToken insertToken) throws SoneException {
 +              String filenameHint = image.getId() + "." + temporaryImage.getMimeType().substring(temporaryImage.getMimeType().lastIndexOf("/") + 1);
 +              InsertableClientSSK key = InsertableClientSSK.createRandom(node.random, "");
 +              FreenetURI targetUri = key.getInsertURI().setDocName(filenameHint);
 +              InsertContext insertContext = client.getInsertContext(true);
 +              Bucket bucket = new ArrayBucket(temporaryImage.getImageData());
 +              ClientMetadata metadata = new ClientMetadata(temporaryImage.getMimeType());
 +              InsertBlock insertBlock = new InsertBlock(bucket, metadata, targetUri);
 +              try {
 +                      ClientPutter clientPutter = client.insert(insertBlock, false, null, false, insertContext, insertToken, RequestStarter.INTERACTIVE_PRIORITY_CLASS);
 +                      insertToken.setClientPutter(clientPutter);
 +              } catch (InsertException ie1) {
 +                      throw new SoneException(Type.INSERT_FAILED, "Could not start image insert.", ie1);
 +              }
 +      }
 +
 +      /**
         * Inserts a directory into Freenet.
         *
         * @param insertUri
  
        }
  
 +      /**
 +       * Insert token that can be used to add {@link ImageInsertListener}s and
 +       * cancel a running insert.
 +       *
 +       * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 +       */
 +      public class InsertToken implements ClientPutCallback {
 +
 +              /** The image being inserted. */
 +              private final Image image;
 +
 +              /** The list of registered image insert listeners. */
 +              private final List<ImageInsertListener> imageInsertListeners = Collections.synchronizedList(new ArrayList<ImageInsertListener>());
 +
 +              /** The client putter. */
 +              private ClientPutter clientPutter;
 +
 +              /** The final URI. */
 +              private volatile FreenetURI resultingUri;
 +
 +              /**
 +               * Creates a new insert token for the given image.
 +               *
 +               * @param image
 +               *            The image being inserted
 +               */
 +              public InsertToken(Image image) {
 +                      this.image = image;
 +              }
 +
 +              //
 +              // LISTENER MANAGEMENT
 +              //
 +
 +              /**
 +               * Adds the given listener to the list of registered listener.
 +               *
 +               * @param imageInsertListener
 +               *            The listener to add
 +               */
 +              public void addImageInsertListener(ImageInsertListener imageInsertListener) {
 +                      imageInsertListeners.add(imageInsertListener);
 +              }
 +
 +              /**
 +               * Removes the given listener from the list of registered listener.
 +               *
 +               * @param imageInsertListener
 +               *            The listener to remove
 +               */
 +              public void removeImageInsertListener(ImageInsertListener imageInsertListener) {
 +                      imageInsertListeners.remove(imageInsertListener);
 +              }
 +
 +              //
 +              // ACCESSORS
 +              //
 +
 +              /**
 +               * Sets the client putter that is inserting the image. This will also
 +               * signal all registered listeners that the image has started.
 +               *
 +               * @see ImageInsertListener#imageInsertStarted(Image)
 +               * @param clientPutter
 +               *            The client putter
 +               */
 +              public void setClientPutter(ClientPutter clientPutter) {
 +                      this.clientPutter = clientPutter;
 +                      for (ImageInsertListener imageInsertListener : imageInsertListeners) {
 +                              imageInsertListener.imageInsertStarted(image);
 +                      }
 +              }
 +
 +              //
 +              // ACTIONS
 +              //
 +
 +              /**
 +               * Cancels the running insert.
 +               *
 +               * @see ImageInsertListener#imageInsertAborted(Image)
 +               */
 +              @SuppressWarnings("synthetic-access")
 +              public void cancel() {
 +                      clientPutter.cancel(null, node.clientCore.clientContext);
 +                      for (ImageInsertListener imageInsertListener : imageInsertListeners) {
 +                              imageInsertListener.imageInsertAborted(image);
 +                      }
 +              }
 +
 +              //
 +              // INTERFACE ClientPutCallback
 +              //
 +
 +              /**
 +               * {@inheritDoc}
 +               */
 +              @Override
 +              public void onMajorProgress(ObjectContainer objectContainer) {
 +                      /* ignore, we don’t care. */
 +              }
 +
 +              /**
 +               * {@inheritDoc}
 +               */
 +              @Override
 +              public void onFailure(InsertException insertException, BaseClientPutter clientPutter, ObjectContainer objectContainer) {
 +                      for (ImageInsertListener imageInsertListener : imageInsertListeners) {
 +                              if ((insertException != null) && ("Cancelled by user".equals(insertException.getMessage()))) {
 +                                      imageInsertListener.imageInsertAborted(image);
 +                              } else {
 +                                      imageInsertListener.imageInsertFailed(image, insertException);
 +                              }
 +                      }
 +              }
 +
 +              /**
 +               * {@inheritDoc}
 +               */
 +              @Override
 +              public void onFetchable(BaseClientPutter clientPutter, ObjectContainer objectContainer) {
 +                      /* ignore, we don’t care. */
 +              }
 +
 +              /**
 +               * {@inheritDoc}
 +               */
 +              @Override
 +              public void onGeneratedURI(FreenetURI generatedUri, BaseClientPutter clientPutter, ObjectContainer objectContainer) {
 +                      resultingUri = generatedUri;
 +              }
 +
 +              /**
 +               * {@inheritDoc}
 +               */
 +              @Override
 +              public void onSuccess(BaseClientPutter clientPutter, ObjectContainer objectContainer) {
 +                      for (ImageInsertListener imageInsertListener : imageInsertListeners) {
 +                              imageInsertListener.imageInsertFinished(image, resultingUri);
 +                      }
 +              }
 +
 +      }
 +
  }
  
  package net.pterodactylus.sone.core;
  
 -import java.io.IOException;
  import java.io.InputStream;
  import java.net.MalformedURLException;
 +import java.util.ArrayList;
  import java.util.HashSet;
 +import java.util.List;
  import java.util.Set;
  import java.util.logging.Level;
  import java.util.logging.Logger;
  
- import net.pterodactylus.sone.core.Core.Preferences;
  import net.pterodactylus.sone.core.Core.SoneStatus;
 +import net.pterodactylus.sone.data.Album;
  import net.pterodactylus.sone.data.Client;
 +import net.pterodactylus.sone.data.Image;
  import net.pterodactylus.sone.data.Post;
  import net.pterodactylus.sone.data.Profile;
  import net.pterodactylus.sone.data.Reply;
@@@ -122,13 -118,12 +121,12 @@@ public class SoneDownloader extends Abs
         *            The Sone to fetch
         */
        public void fetchSone(Sone sone) {
-               fetchSone(sone, sone.getRequestUri());
+               fetchSone(sone, sone.getRequestUri().sskForUSK());
        }
  
        /**
         * Fetches the updated Sone. This method can be used to fetch a Sone from a
-        * specific URI (which happens when {@link Preferences#isSoneRescueMode()
-        * „Sone rescue mode“} is active).
+        * specific URI.
         *
         * @param sone
         *            The Sone to fetch
         *            The URI to fetch the Sone from
         */
        public void fetchSone(Sone sone, FreenetURI soneUri) {
+               fetchSone(sone, soneUri, false);
+       }
+       /**
+        * Fetches the Sone from the given URI.
+        *
+        * @param sone
+        *            The Sone to fetch
+        * @param soneUri
+        *            The URI of the Sone to fetch
+        * @param fetchOnly
+        *            {@code true} to only fetch and parse the Sone, {@code false}
+        *            to {@link Core#updateSone(Sone) update} it in the core
+        * @return The downloaded Sone, or {@code null} if the Sone could not be
+        *         downloaded
+        */
+       public Sone fetchSone(Sone sone, FreenetURI soneUri, boolean fetchOnly) {
                logger.log(Level.FINE, "Starting fetch for Sone “%s” from %s…", new Object[] { sone, soneUri });
                FreenetURI requestUri = soneUri.setMetaString(new String[] { "sone.xml" });
                core.setSoneStatus(sone, SoneStatus.downloading);
                        Pair<FreenetURI, FetchResult> fetchResults = freenetInterface.fetchUri(requestUri);
                        if (fetchResults == null) {
                                /* TODO - mark Sone as bad. */
-                               return;
+                               return null;
                        }
                        logger.log(Level.FINEST, "Got %d bytes back.", fetchResults.getRight().size());
                        Sone parsedSone = parseSone(sone, fetchResults.getRight(), fetchResults.getLeft());
                        if (parsedSone != null) {
-                               addSone(parsedSone);
-                               core.updateSone(parsedSone);
+                               if (!fetchOnly) {
+                                       core.updateSone(parsedSone);
+                                       addSone(parsedSone);
+                               }
                        }
+                       return parsedSone;
                } finally {
                        core.setSoneStatus(sone, (sone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
                }
                                }
                        }
                        return parsedSone;
 -              } catch (IOException ioe1) {
 -                      logger.log(Level.WARNING, "Could not parse Sone from " + requestUri + "!", ioe1);
 +              } catch (Exception e1) {
 +                      logger.log(Level.WARNING, "Could not parse Sone from " + requestUri + "!", e1);
                } finally {
                        Closer.close(soneInputStream);
                        soneBucket.free();
                        }
                }
  
 +              /* parse albums. */
 +              SimpleXML albumsXml = soneXml.getNode("albums");
 +              List<Album> topLevelAlbums = new ArrayList<Album>();
 +              if (albumsXml != null) {
 +                      for (SimpleXML albumXml : albumsXml.getNodes("album")) {
 +                              String id = albumXml.getValue("id", null);
 +                              String parentId = albumXml.getValue("parent", null);
 +                              String title = albumXml.getValue("title", null);
 +                              String description = albumXml.getValue("description", null);
 +                              if ((id == null) || (title == null) || (description == null)) {
 +                                      logger.log(Level.WARNING, "Downloaded Sone %s contains invalid album!", new Object[] { sone });
 +                                      return null;
 +                              }
 +                              Album parent = null;
 +                              if (parentId != null) {
 +                                      parent = core.getAlbum(parentId, false);
 +                                      if (parent == null) {
 +                                              logger.log(Level.WARNING, "Downloaded Sone %s has album with invalid parent!", new Object[] { sone });
 +                                              return null;
 +                                      }
 +                              }
 +                              Album album = core.getAlbum(id).setSone(sone).setTitle(title).setDescription(description);
 +                              if (parent != null) {
 +                                      parent.addAlbum(album);
 +                              } else {
 +                                      topLevelAlbums.add(album);
 +                              }
 +                              SimpleXML imagesXml = albumXml.getNode("images");
 +                              if (imagesXml != null) {
 +                                      for (SimpleXML imageXml : imagesXml.getNodes("image")) {
 +                                              String imageId = imageXml.getValue("id", null);
 +                                              String imageCreationTimeString = imageXml.getValue("creation-time", null);
 +                                              String imageKey = imageXml.getValue("key", null);
 +                                              String imageTitle = imageXml.getValue("title", null);
 +                                              String imageDescription = imageXml.getValue("description", "");
 +                                              String imageWidthString = imageXml.getValue("width", null);
 +                                              String imageHeightString = imageXml.getValue("height", null);
 +                                              if ((imageId == null) || (imageCreationTimeString == null) || (imageKey == null) || (imageTitle == null) || (imageWidthString == null) || (imageHeightString == null)) {
 +                                                      logger.log(Level.WARNING, "Downloaded Sone %s contains invalid images!", new Object[] { sone });
 +                                                      return null;
 +                                              }
 +                                              long creationTime = Numbers.safeParseLong(imageCreationTimeString, 0L);
 +                                              int imageWidth = Numbers.safeParseInteger(imageWidthString, 0);
 +                                              int imageHeight = Numbers.safeParseInteger(imageHeightString, 0);
 +                                              if ((imageWidth < 1) || (imageHeight < 1)) {
 +                                                      logger.log(Level.WARNING, "Downloaded Sone %s contains image %s with invalid dimensions (%s, %s)!", new Object[] { sone, imageId, imageWidthString, imageHeightString });
 +                                                      return null;
 +                                              }
 +                                              Image image = core.getImage(imageId).setSone(sone).setKey(imageKey).setCreationTime(creationTime);
 +                                              image.setTitle(imageTitle).setDescription(imageDescription);
 +                                              image.setWidth(imageWidth).setHeight(imageHeight);
 +                                              album.addImage(image);
 +                                      }
 +                              }
 +                      }
 +              }
 +
                /* okay, apparently everything was parsed correctly. Now import. */
                /* atomic setter operation on the Sone. */
                synchronized (sone) {
                        sone.setReplies(replies);
                        sone.setLikePostIds(likedPostIds);
                        sone.setLikeReplyIds(likedReplyIds);
 +                      sone.setAlbums(topLevelAlbums);
                }
  
                return sone;
@@@ -1,5 -1,5 +1,5 @@@
  /*
-  * FreenetSone - SoneException.java - Copyright © 2010 David Roden
+  * Sone - SoneException.java - Copyright © 2010 David Roden
   *
   * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
@@@ -37,9 -37,6 +37,9 @@@ public class SoneException extends Exce
                /** An invalid URI was specified. */
                INVALID_URI,
  
 +              /** An insert failed. */
 +              INSERT_FAILED,
 +
        }
  
        /** The type of the exception. */
@@@ -1,5 -1,5 +1,5 @@@
  /*
-  * FreenetSone - SoneInserter.java - Copyright © 2010 David Roden
+  * Sone - SoneInserter.java - Copyright © 2010 David Roden
   *
   * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
@@@ -33,6 -33,8 +33,8 @@@ import net.pterodactylus.sone.data.Repl
  import net.pterodactylus.sone.data.Sone;
  import net.pterodactylus.sone.freenet.StringBucket;
  import net.pterodactylus.sone.main.SonePlugin;
+ import net.pterodactylus.util.collection.ListBuilder;
+ import net.pterodactylus.util.collection.ReverseComparator;
  import net.pterodactylus.util.io.Closer;
  import net.pterodactylus.util.logging.Logging;
  import net.pterodactylus.util.service.AbstractService;
@@@ -81,6 -83,9 +83,9 @@@ public class SoneInserter extends Abstr
        /** The Sone to insert. */
        private final Sone sone;
  
+       /** The insert listener manager. */
+       private SoneInsertListenerManager soneInsertListenerManager;
        /** Whether a modification has been detected. */
        private volatile boolean modified = false;
  
                this.core = core;
                this.freenetInterface = freenetInterface;
                this.sone = sone;
+               this.soneInsertListenerManager = new SoneInsertListenerManager(sone);
+       }
+       //
+       // LISTENER MANAGEMENT
+       //
+       /**
+        * Adds a listener for Sone insert events.
+        *
+        * @param soneInsertListener
+        *            The Sone insert listener
+        */
+       public void addSoneInsertListener(SoneInsertListener soneInsertListener) {
+               soneInsertListenerManager.addListener(soneInsertListener);
+       }
+       /**
+        * Removes a listener for Sone insert events.
+        *
+        * @param soneInsertListener
+        *            The Sone insert listener
+        */
+       public void removeSoneInsertListener(SoneInsertListener soneInsertListener) {
+               soneInsertListenerManager.removeListener(soneInsertListener);
        }
  
        //
                                        core.setSoneStatus(sone, SoneStatus.inserting);
                                        long insertTime = System.currentTimeMillis();
                                        insertInformation.setTime(insertTime);
+                                       soneInsertListenerManager.fireInsertStarted();
                                        FreenetURI finalUri = freenetInterface.insertDirectory(insertInformation.getInsertUri(), insertInformation.generateManifestEntries(), "index.html");
+                                       soneInsertListenerManager.fireInsertFinished(System.currentTimeMillis() - insertTime);
                                        /* at this point we might already be stopped. */
                                        if (shouldStop()) {
                                                /* if so, bail out, don’t change anything. */
                                        }
                                        sone.setTime(insertTime);
                                        sone.setLatestEdition(finalUri.getEdition());
-                                       core.saveSone(sone);
+                                       core.touchConfiguration();
                                        success = true;
                                        logger.log(Level.INFO, "Inserted Sone “%s” at %s.", new Object[] { sone.getName(), finalUri });
                                } catch (SoneException se1) {
+                                       soneInsertListenerManager.fireInsertAborted(se1);
                                        logger.log(Level.WARNING, "Could not insert Sone “" + sone.getName() + "”!", se1);
                                } finally {
                                        core.setSoneStatus(sone, SoneStatus.idle);
                                        synchronized (sone) {
                                                if (lastInsertFingerprint.equals(sone.getFingerprint())) {
                                                        logger.log(Level.FINE, "Sone “%s” was not modified further, resetting counter…", new Object[] { sone });
-                                                       core.saveSone(sone);
                                                        lastModificationTime = 0;
                                                        modified = false;
                                                }
         *
         * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
         */
-       private static class InsertInformation {
+       private class InsertInformation {
  
                /** All properties of the Sone, copied for thread safety. */
                private final Map<String, Object> soneProperties = new HashMap<String, Object>();
                        soneProperties.put("requestUri", sone.getRequestUri());
                        soneProperties.put("insertUri", sone.getInsertUri());
                        soneProperties.put("profile", sone.getProfile());
-                       soneProperties.put("posts", new ArrayList<Post>(sone.getPosts()));
-                       soneProperties.put("replies", new HashSet<Reply>(sone.getReplies()));
+                       soneProperties.put("posts", new ListBuilder<Post>(new ArrayList<Post>(sone.getPosts())).sort(Post.TIME_COMPARATOR).get());
+                       soneProperties.put("replies", new ListBuilder<Reply>(new ArrayList<Reply>(sone.getReplies())).sort(new ReverseComparator<Reply>(Reply.TIME_COMPARATOR)).get());
                        soneProperties.put("likedPostIds", new HashSet<String>(sone.getLikedPostIds()));
                        soneProperties.put("likedReplyIds", new HashSet<String>(sone.getLikedReplyIds()));
 +                      soneProperties.put("albums", Sone.flattenAlbums(sone.getAlbums()));
                }
  
                //
                        }
  
                        TemplateContext templateContext = templateContextFactory.createTemplateContext();
+                       templateContext.set("core", core);
                        templateContext.set("currentSone", soneProperties);
+                       templateContext.set("currentEdition", core.getUpdateChecker().getLatestEdition());
                        templateContext.set("version", SonePlugin.VERSION);
                        StringWriter writer = new StringWriter();
                        StringBucket bucket = null;
@@@ -1,5 -1,5 +1,5 @@@
  /*
-  * FreenetSone - Sone.java - Copyright © 2010 David Roden
+  * Sone - Sone.java - Copyright © 2010 David Roden
   *
   * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
@@@ -27,12 -27,13 +27,14 @@@ import java.util.Set
  import java.util.logging.Level;
  import java.util.logging.Logger;
  
+ import net.pterodactylus.sone.core.Core;
  import net.pterodactylus.sone.core.Options;
  import net.pterodactylus.sone.freenet.wot.Identity;
+ import net.pterodactylus.sone.freenet.wot.OwnIdentity;
  import net.pterodactylus.sone.template.SoneAccessor;
  import net.pterodactylus.util.filter.Filter;
  import net.pterodactylus.util.logging.Logging;
 +import net.pterodactylus.util.validation.Validation;
  import freenet.keys.FreenetURI;
  
  /**
@@@ -59,6 -60,29 +61,29 @@@ public class Sone implements Fingerprin
  
        };
  
+       /**
+        * Comparator that sorts Sones by last activity (least recent active first).
+        */
+       public static final Comparator<Sone> LAST_ACTIVITY_COMPARATOR = new Comparator<Sone>() {
+               @Override
+               public int compare(Sone firstSone, Sone secondSone) {
+                       return (int) Math.min(Integer.MAX_VALUE, Math.max(Integer.MIN_VALUE, secondSone.getTime() - firstSone.getTime()));
+               }
+       };
+       /** Comparator that sorts Sones by numbers of posts (descending). */
+       public static final Comparator<Sone> POST_COUNT_COMPARATOR = new Comparator<Sone>() {
+               /**
+                * {@inheritDoc}
+                */
+               @Override
+               public int compare(Sone leftSone, Sone rightSone) {
+                       return (leftSone.getPosts().size() != rightSone.getPosts().size()) ? (rightSone.getPosts().size() - leftSone.getPosts().size()) : (rightSone.getReplies().size() - leftSone.getReplies().size());
+               }
+       };
        /** Filter to remove Sones that have not been downloaded. */
        public static final Filter<Sone> EMPTY_SONE_FILTER = new Filter<Sone>() {
  
                }
        };
  
+       /** Filter that matches all {@link Core#isLocalSone(Sone) local Sones}. */
+       public static final Filter<Sone> LOCAL_SONE_FILTER = new Filter<Sone>() {
+               @Override
+               public boolean filterObject(Sone sone) {
+                       return sone.getIdentity() instanceof OwnIdentity;
+               }
+       };
        /** The logger. */
        private static final Logger logger = Logging.getLogger(Sone.class);
  
        /** The IDs of all liked replies. */
        private final Set<String> likedReplyIds = Collections.synchronizedSet(new HashSet<String>());
  
 +      /** The albums of this Sone. */
 +      private final List<Album> albums = Collections.synchronizedList(new ArrayList<Album>());
 +
        /** Sone-specific options. */
        private final Options options = new Options();
  
         */
        public void setLatestEdition(long latestEdition) {
                if (!(latestEdition > this.latestEdition)) {
 -                      logger.log(Level.INFO, "New latest edition %d is not greater than current latest edition %d!", new Object[] { latestEdition, this.latestEdition });
 +                      logger.log(Level.FINE, "New latest edition %d is not greater than current latest edition %d!", new Object[] { latestEdition, this.latestEdition });
                        return;
                }
                this.latestEdition = latestEdition;
        }
  
        /**
 +       * Returns the albums of this Sone.
 +       *
 +       * @return The albums of this Sone
 +       */
 +      public List<Album> getAlbums() {
 +              return Collections.unmodifiableList(albums);
 +      }
 +
 +      /**
 +       * Adds an album to this Sone.
 +       *
 +       * @param album
 +       *            The album to add
 +       */
 +      public synchronized void addAlbum(Album album) {
 +              Validation.begin().isNotNull("Album", album).check().isEqual("Album Owner", album.getSone(), this).check();
 +              albums.add(album);
 +      }
 +
 +      /**
 +       * Sets the albums of this Sone.
 +       *
 +       * @param albums
 +       *            The albums of this Sone
 +       */
 +      public synchronized void setAlbums(Collection<? extends Album> albums) {
 +              Validation.begin().isNotNull("Albums", albums).check();
 +              this.albums.clear();
 +              for (Album album : albums) {
 +                      addAlbum(album);
 +              }
 +      }
 +
 +      /**
 +       * Removes an album from this Sone.
 +       *
 +       * @param album
 +       *            The album to remove
 +       */
 +      public synchronized void removeAlbum(Album album) {
 +              Validation.begin().isNotNull("Album", album).check().isEqual("Album Owner", album.getSone(), this).check();
 +              albums.remove(album);
 +      }
 +
 +      /**
         * Returns Sone-specific options.
         *
         * @return The options of this Sone
                }
                fingerprint.append(")");
  
+               @SuppressWarnings("hiding")
                List<Reply> replies = new ArrayList<Reply>(getReplies());
                Collections.sort(replies, Reply.TIME_COMPARATOR);
                fingerprint.append("Replies(");
                }
                fingerprint.append(')');
  
+               @SuppressWarnings("hiding")
                List<String> likedPostIds = new ArrayList<String>(getLikedPostIds());
                Collections.sort(likedPostIds);
                fingerprint.append("LikedPosts(");
                }
                fingerprint.append(')');
  
+               @SuppressWarnings("hiding")
                List<String> likedReplyIds = new ArrayList<String>(getLikedReplyIds());
                Collections.sort(likedReplyIds);
                fingerprint.append("LikedReplies(");
                }
                fingerprint.append(')');
  
 +              fingerprint.append("Albums(");
 +              for (Album album : albums) {
 +                      fingerprint.append(album.getFingerprint());
 +              }
 +              fingerprint.append(')');
 +
                return fingerprint.toString();
        }
  
        //
 +      // STATIC METHODS
 +      //
 +
 +      /**
 +       * Flattens the given top-level albums so that the resulting list contains
 +       * parent albums before child albums and the resulting list can be parsed in
 +       * a single pass.
 +       *
 +       * @param albums
 +       *            The albums to flatten
 +       * @return The flattened albums
 +       */
 +      public static List<Album> flattenAlbums(Collection<? extends Album> albums) {
 +              List<Album> flatAlbums = new ArrayList<Album>();
 +              flatAlbums.addAll(albums);
 +              int lastAlbumIndex = 0;
 +              while (lastAlbumIndex < flatAlbums.size()) {
 +                      int previousAlbumCount = flatAlbums.size();
 +                      for (Album album : new ArrayList<Album>(flatAlbums.subList(lastAlbumIndex, flatAlbums.size()))) {
 +                              flatAlbums.addAll(album.getAlbums());
 +                      }
 +                      lastAlbumIndex = previousAlbumCount;
 +              }
 +              return flatAlbums;
 +      }
 +
 +      //
        // INTERFACE Comparable<Sone>
        //
  
index afe95b7,0000000..a695952
mode 100644,000000..100644
--- /dev/null
@@@ -1,72 -1,0 +1,73 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * Sone - CreateAlbumPage.java - Copyright © 2011 David Roden
 + *
 + * This program 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.
 + *
 + * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Album;
 +import net.pterodactylus.sone.data.Sone;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +
 +/**
 + * Page that lets the user create a new album.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class CreateAlbumPage extends SoneTemplatePage {
 +
 +      /**
 +       * Creates a new “create album” page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public CreateAlbumPage(Template template, WebInterface webInterface) {
 +              super("createAlbum.html", template, "Page.CreateAlbum.Title", webInterface, true);
 +      }
 +
 +      //
 +      // SONETEMPLATEPAGE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
-                       webInterface.getCore().saveSone(currentSone);
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              if (request.getMethod() == Method.POST) {
 +                      String name = request.getHttpRequest().getPartAsStringFailsafe("name", 64).trim();
 +                      if (name.length() == 0) {
 +                              templateContext.set("nameMissing", true);
 +                              return;
 +                      }
 +                      String description = request.getHttpRequest().getPartAsStringFailsafe("description", 256).trim();
 +                      Sone currentSone = getCurrentSone(request.getToadletContext());
 +                      String parentId = request.getHttpRequest().getPartAsStringFailsafe("parent", 36);
 +                      Album parent = webInterface.getCore().getAlbum(parentId, false);
 +                      Album album = webInterface.getCore().createAlbum(currentSone, parent);
 +                      album.setTitle(name).setDescription(description);
++                      webInterface.getCore().touchConfiguration();
 +                      throw new RedirectException("imageBrowser.html?album=" + album.getId());
 +              }
 +      }
 +
 +}
index 6855e38,0000000..d03e065
mode 100644,000000..100644
--- /dev/null
@@@ -1,77 -1,0 +1,78 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * Sone - DeleteAlbumPage.java - Copyright © 2011 David Roden
 + *
 + * This program 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.
 + *
 + * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Album;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +
 +/**
 + * Page that lets the user delete an {@link Album}.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class DeleteAlbumPage extends SoneTemplatePage {
 +
 +      /**
 +       * Creates a new “delete album” page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public DeleteAlbumPage(Template template, WebInterface webInterface) {
 +              super("deleteAlbum.html", template, "Page.DeleteAlbum.Title", webInterface, true);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              if (request.getMethod() == Method.POST) {
 +                      String albumId = request.getHttpRequest().getPartAsStringFailsafe("album", 36);
 +                      Album album = webInterface.getCore().getAlbum(albumId, false);
 +                      if (album == null) {
 +                              throw new RedirectException("invalid.html");
 +                      }
 +                      if (!webInterface.getCore().isLocalSone(album.getSone())) {
 +                              throw new RedirectException("noPermission.html");
 +                      }
 +                      if (request.getHttpRequest().isPartSet("abortDelete")) {
 +                              throw new RedirectException("imageBrowser.html?album=" + album.getId());
 +                      }
 +                      Album parentAlbum = album.getParent();
 +                      webInterface.getCore().deleteAlbum(album);
 +                      if (parentAlbum == null) {
 +                              throw new RedirectException("imageBrowser.html?sone=" + album.getSone().getId());
 +                      }
 +                      throw new RedirectException("imageBrowser.html?album=" + parentAlbum.getId());
 +              }
 +              String albumId = request.getHttpRequest().getParam("album");
 +              Album album = webInterface.getCore().getAlbum(albumId, false);
 +              if (album == null) {
 +                      throw new RedirectException("invalid.html");
 +              }
 +              templateContext.set("album", album);
 +      }
 +
 +}
index 999a38a,0000000..66098ff
mode 100644,000000..100644
--- /dev/null
@@@ -1,72 -1,0 +1,73 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * Sone - DeleteImagePage.java - Copyright © 2011 David Roden
 + *
 + * This program 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.
 + *
 + * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Image;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +
 +/**
 + * Page that lets the user delete an {@link Image}.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class DeleteImagePage extends SoneTemplatePage {
 +
 +      /**
 +       * Creates a new “delete image” page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public DeleteImagePage(Template template, WebInterface webInterface) {
 +              super("deleteImage.html", template, "Page.DeleteImage.Title", webInterface, true);
 +      }
 +
 +      //
 +      // SONETEMPLATEPAGE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              String imageId = (request.getMethod() == Method.POST) ? request.getHttpRequest().getPartAsStringFailsafe("image", 36) : request.getHttpRequest().getParam("image");
 +              Image image = webInterface.getCore().getImage(imageId, false);
 +              if (image == null) {
 +                      throw new RedirectException("invalid.html");
 +              }
 +              if (!webInterface.getCore().isLocalSone(image.getSone())) {
 +                      throw new RedirectException("noPermission.html");
 +              }
 +              if (request.getMethod() == Method.POST) {
 +                      if (request.getHttpRequest().isPartSet("abortDelete")) {
 +                              throw new RedirectException("imageBrowser.html?image=" + image.getId());
 +                      }
 +                      webInterface.getCore().deleteImage(image);
 +                      throw new RedirectException("imageBrowser.html?album=" + image.getAlbum().getId());
 +              }
 +              templateContext.set("image", image);
 +      }
 +
 +}
index eaf8bf6,0000000..c3c2525
mode 100644,000000..100644
--- /dev/null
@@@ -1,69 -1,0 +1,70 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * Sone - EditAlbumPage.java - Copyright © 2011 David Roden
 + *
 + * This program 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.
 + *
 + * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Album;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +
 +/**
 + * TODO
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class EditAlbumPage extends SoneTemplatePage {
 +
 +      /**
 +       * TODO
 +       *
 +       * @param template
 +       * @param webInterface
 +       */
 +      public EditAlbumPage(Template template, WebInterface webInterface) {
 +              super("editAlbum.html", template, "Page.EditAlbum.Title", webInterface, true);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
-                       webInterface.getCore().saveSone(album.getSone());
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              if (request.getMethod() == Method.POST) {
 +                      String albumId = request.getHttpRequest().getPartAsStringFailsafe("album", 36);
 +                      Album album = webInterface.getCore().getAlbum(albumId, false);
 +                      if (album == null) {
 +                              throw new RedirectException("invalid.html");
 +                      }
 +                      if (!webInterface.getCore().isLocalSone(album.getSone())) {
 +                              throw new RedirectException("noPermission.html");
 +                      }
 +                      String title = request.getHttpRequest().getPartAsStringFailsafe("title", 100).trim();
 +                      if (title.length() == 0) {
 +                              templateContext.set("titleMissing", true);
 +                              return;
 +                      }
 +                      String description = request.getHttpRequest().getPartAsStringFailsafe("description", 1000).trim();
 +                      album.setTitle(title).setDescription(description);
++                      webInterface.getCore().touchConfiguration();
 +                      throw new RedirectException("imageBrowser.html?album=" + album.getId());
 +              }
 +      }
 +
 +}
index eab3bb9,0000000..afb8787
mode 100644,000000..100644
--- /dev/null
@@@ -1,75 -1,0 +1,76 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * FreenetSone - WebInterface.java - Copyright © 2010 David Roden
 + *
 + * This program 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.
 + *
 + * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Image;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +
 +/**
 + * Page that lets the user edit title and description of an {@link Image}.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class EditImagePage extends SoneTemplatePage {
 +
 +      /**
 +       * Creates a new “edit image” page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public EditImagePage(Template template, WebInterface webInterface) {
 +              super("editImage.html", template, "Page.EditImage.Title", webInterface, true);
 +      }
 +
 +      //
 +      // SONETEMPLATEPAGE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
-                       webInterface.getCore().saveSone(image.getSone());
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              if (request.getMethod() == Method.POST) {
 +                      String imageId = request.getHttpRequest().getPartAsStringFailsafe("image", 36);
 +                      Image image = webInterface.getCore().getImage(imageId, false);
 +                      if (image == null) {
 +                              throw new RedirectException("invalid.html");
 +                      }
 +                      if (!webInterface.getCore().isLocalSone(image.getSone())) {
 +                              throw new RedirectException("noPermission.html");
 +                      }
 +                      String title = request.getHttpRequest().getPartAsStringFailsafe("title", 100).trim();
 +                      String description = request.getHttpRequest().getPartAsStringFailsafe("description", 1024).trim();
 +                      if (title.length() == 0) {
 +                              templateContext.set("titleMissing", true);
 +                      }
 +                      image.setTitle(title);
 +                      image.setDescription(description);
++                      webInterface.getCore().touchConfiguration();
 +                      throw new RedirectException("imageBrowser.html?image=" + image.getId());
 +              }
 +      }
 +
 +}
index fdd72a8,0000000..29556c9
mode 100644,000000..100644
--- /dev/null
@@@ -1,64 -1,0 +1,77 @@@
- import net.pterodactylus.sone.web.page.Page;
 +/*
 + * Sone - GetImagePage.java - Copyright © 2011 David Roden
 + *
 + * This program 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.
 + *
 + * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
++import java.io.IOException;
++
 +import net.pterodactylus.sone.data.TemporaryImage;
- public class GetImagePage implements Page {
++import net.pterodactylus.sone.web.page.FreenetRequest;
++import net.pterodactylus.util.web.Page;
++import net.pterodactylus.util.web.Response;
 +
 +/**
 + * Page that delivers a {@link TemporaryImage} to the browser.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
-       public Response handleRequest(Request request) {
++public class GetImagePage implements Page<FreenetRequest> {
 +
 +      /** The Sone web interface. */
 +      private final WebInterface webInterface;
 +
 +      /**
 +       * Creates a new “get image” page.
 +       *
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public GetImagePage(WebInterface webInterface) {
 +              this.webInterface = webInterface;
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public String getPath() {
 +              return "getImage.html";
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
-                       return new Response(404, "Not found.", "text/plain; charset=utf-8", "");
++      public boolean isPrefixPage() {
++              return false;
++      }
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      public Response handleRequest(FreenetRequest request, Response response) throws IOException {
 +              String imageId = request.getHttpRequest().getParam("image");
 +              TemporaryImage temporaryImage = webInterface.getCore().getTemporaryImage(imageId);
 +              if (temporaryImage == null) {
-               return new Response(200, "OK", temporaryImage.getMimeType(), temporaryImage.getImageData()).setHeader("Content-Disposition", "attachment; filename=" + temporaryImage.getId() + "." + temporaryImage.getMimeType().substring(temporaryImage.getMimeType().lastIndexOf("/") + 1));
++                      return response.setStatusCode(404).setStatusText("Not found.").setContentType("text/html; charset=utf-8");
 +              }
++              String contentType= temporaryImage.getMimeType();
++              return response.setStatusCode(200).setStatusText("OK").setContentType(contentType).addHeader("Content-Disposition", "attachment; filename=" + temporaryImage.getId() + "." + contentType.substring(contentType.lastIndexOf('/') + 1)).write(temporaryImage.getImageData());
 +      }
 +
 +}
index 778b429,0000000..ed31283
mode 100644,000000..100644
--- /dev/null
@@@ -1,78 -1,0 +1,79 @@@
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
 +/*
 + * Sone - ImageBrowserPage.java - Copyright © 2011 David Roden
 + *
 + * This program 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.
 + *
 + * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Album;
 +import net.pterodactylus.sone.data.Image;
 +import net.pterodactylus.sone.data.Sone;
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
 +
 +/**
 + * The image browser page is the entry page for the image management.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class ImageBrowserPage extends SoneTemplatePage {
 +
 +      /**
 +       * Creates a new image browser page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public ImageBrowserPage(Template template, WebInterface webInterface) {
 +              super("imageBrowser.html", template, "Page.ImageBrowser.Title", webInterface, true);
 +      }
 +
 +      //
 +      // SONETEMPLATEPAGE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              String albumId = request.getHttpRequest().getParam("album", null);
 +              if (albumId != null) {
 +                      Album album = webInterface.getCore().getAlbum(albumId, false);
 +                      templateContext.set("albumRequested", true);
 +                      templateContext.set("album", album);
 +                      return;
 +              }
 +              String imageId = request.getHttpRequest().getParam("image", null);
 +              if (imageId != null) {
 +                      Image image = webInterface.getCore().getImage(imageId, false);
 +                      templateContext.set("imageRequested", true);
 +                      templateContext.set("image", image);
 +                      return;
 +              }
 +              Sone sone = getCurrentSone(request.getToadletContext(), false);
 +              String soneId = request.getHttpRequest().getParam("sone", null);
 +              if (soneId != null) {
 +                      sone = webInterface.getCore().getSone(soneId, false);
 +              }
 +              templateContext.set("soneRequested", true);
 +              templateContext.set("sone", sone);
 +      }
 +
 +}
index 4d98179,0000000..e0aa1e1
mode 100644,000000..100644
--- /dev/null
@@@ -1,160 -1,0 +1,161 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * Sone - UploadImagePage.java - Copyright © 2011 David Roden
 + *
 + * This program 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.
 + *
 + * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import java.awt.Image;
 +import java.io.ByteArrayInputStream;
 +import java.io.ByteArrayOutputStream;
 +import java.io.IOException;
 +import java.io.InputStream;
 +import java.util.Iterator;
 +import java.util.logging.Level;
 +import java.util.logging.Logger;
 +
 +import javax.imageio.ImageIO;
 +import javax.imageio.ImageReader;
 +import javax.imageio.stream.ImageInputStream;
 +
 +import net.pterodactylus.sone.data.Album;
 +import net.pterodactylus.sone.data.Sone;
 +import net.pterodactylus.sone.data.TemporaryImage;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.io.Closer;
 +import net.pterodactylus.util.io.StreamCopier;
 +import net.pterodactylus.util.logging.Logging;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +import freenet.support.api.Bucket;
 +import freenet.support.api.HTTPUploadedFile;
 +
 +/**
 + * Page implementation that lets the user upload an image.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class UploadImagePage extends SoneTemplatePage {
 +
 +      /** The logger. */
 +      private static final Logger logger = Logging.getLogger(UploadImagePage.class);
 +
 +      /**
 +       * Creates a new “upload image” page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public UploadImagePage(Template template, WebInterface webInterface) {
 +              super("uploadImage.html", template, "Page.UploadImage.Title", webInterface, true);
 +      }
 +
 +      //
 +      // SONETEMPLATEPAGE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              if (request.getMethod() == Method.POST) {
 +                      Sone currentSone = getCurrentSone(request.getToadletContext());
 +                      String parentId = request.getHttpRequest().getPartAsStringFailsafe("parent", 36);
 +                      Album parent = webInterface.getCore().getAlbum(parentId, false);
 +                      if (parent == null) {
 +                              /* TODO - signal error */
 +                              return;
 +                      }
 +                      if (!currentSone.equals(parent.getSone())) {
 +                              /* TODO - signal error. */
 +                              return;
 +                      }
 +                      String name = request.getHttpRequest().getPartAsStringFailsafe("title", 200);
 +                      String description = request.getHttpRequest().getPartAsStringFailsafe("description", 4000);
 +                      HTTPUploadedFile uploadedFile = request.getHttpRequest().getUploadedFile("image");
 +                      Bucket fileBucket = uploadedFile.getData();
 +                      InputStream imageInputStream = null;
 +                      ByteArrayOutputStream imageDataOutputStream = null;
 +                      net.pterodactylus.sone.data.Image image = null;
 +                      try {
 +                              imageInputStream = fileBucket.getInputStream();
 +                              /* TODO - check length */
 +                              imageDataOutputStream = new ByteArrayOutputStream((int) fileBucket.size());
 +                              StreamCopier.copy(imageInputStream, imageDataOutputStream);
 +                      } catch (IOException ioe1) {
 +                              logger.log(Level.WARNING, "Could not read uploaded image!", ioe1);
 +                              return;
 +                      } finally {
 +                              fileBucket.free();
 +                              Closer.close(imageInputStream);
 +                              Closer.close(imageDataOutputStream);
 +                      }
 +                      byte[] imageData = imageDataOutputStream.toByteArray();
 +                      ByteArrayInputStream imageDataInputStream = null;
 +                      Image uploadedImage = null;
 +                      try {
 +                              imageDataInputStream = new ByteArrayInputStream(imageData);
 +                              uploadedImage = ImageIO.read(imageDataInputStream);
 +                              if (uploadedImage == null) {
 +                                      templateContext.set("messages", webInterface.getL10n().getString("Page.UploadImage.Error.InvalidImage"));
 +                                      return;
 +                              }
 +                              String mimeType = getMimeType(imageData);
 +                              TemporaryImage temporaryImage = webInterface.getCore().createTemporaryImage(mimeType, imageData);
 +                              image = webInterface.getCore().createImage(currentSone, parent, temporaryImage);
 +                              image.setTitle(name).setDescription(description).setWidth(uploadedImage.getWidth(null)).setHeight(uploadedImage.getHeight(null));
 +                      } catch (IOException ioe1) {
 +                              logger.log(Level.WARNING, "Could not read uploaded image!", ioe1);
 +                              return;
 +                      } finally {
 +                              Closer.close(imageDataInputStream);
 +                              Closer.flush(uploadedImage);
 +                      }
 +                      throw new RedirectException("imageBrowser.html?album=" + parent.getId());
 +              }
 +      }
 +
 +      //
 +      // PRIVATE METHODS
 +      //
 +
 +      /**
 +       * Tries to detect the MIME type of the encoded image.
 +       *
 +       * @param imageData
 +       *            The encoded image
 +       * @return The MIME type of the image, or “application/octet-stream” if the
 +       *         image type could not be detected
 +       */
 +      private String getMimeType(byte[] imageData) {
 +              ByteArrayInputStream imageDataInputStream = new ByteArrayInputStream(imageData);
 +              try {
 +                      ImageInputStream imageInputStream = ImageIO.createImageInputStream(imageDataInputStream);
 +                      Iterator<ImageReader> imageReaders = ImageIO.getImageReaders(imageInputStream);
 +                      if (imageReaders.hasNext()) {
 +                              return imageReaders.next().getOriginatingProvider().getMIMETypes()[0];
 +                      }
 +              } catch (IOException ioe1) {
 +                      logger.log(Level.FINE, "Could not detect MIME type for image.", ioe1);
 +              }
 +              return "application/octet-stream";
 +      }
 +
 +}
@@@ -1,5 -1,5 +1,5 @@@
  /*
-  * FreenetSone - WebInterface.java - Copyright © 2010 David Roden
+  * Sone - WebInterface.java - Copyright © 2010 David Roden
   *
   * This program is free software: you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
@@@ -17,6 -17,7 +17,7 @@@
  
  package net.pterodactylus.sone.web;
  
+ import java.io.IOException;
  import java.io.InputStream;
  import java.io.InputStreamReader;
  import java.io.Reader;
@@@ -36,8 -37,6 +37,8 @@@ import java.util.logging.Logger
  
  import net.pterodactylus.sone.core.Core;
  import net.pterodactylus.sone.core.CoreListener;
 +import net.pterodactylus.sone.data.Album;
 +import net.pterodactylus.sone.data.Image;
  import net.pterodactylus.sone.data.Post;
  import net.pterodactylus.sone.data.Reply;
  import net.pterodactylus.sone.data.Sone;
@@@ -46,14 -45,11 +47,13 @@@ import net.pterodactylus.sone.freenet.w
  import net.pterodactylus.sone.freenet.wot.Trust;
  import net.pterodactylus.sone.main.SonePlugin;
  import net.pterodactylus.sone.notify.ListNotification;
 +import net.pterodactylus.sone.template.AlbumAccessor;
  import net.pterodactylus.sone.template.CollectionAccessor;
  import net.pterodactylus.sone.template.CssClassNameFilter;
  import net.pterodactylus.sone.template.HttpRequestAccessor;
  import net.pterodactylus.sone.template.IdentityAccessor;
 +import net.pterodactylus.sone.template.ImageLinkFilter;
  import net.pterodactylus.sone.template.JavascriptFilter;
- import net.pterodactylus.sone.template.NotificationManagerAccessor;
  import net.pterodactylus.sone.template.ParserFilter;
  import net.pterodactylus.sone.template.PostAccessor;
  import net.pterodactylus.sone.template.ReplyAccessor;
@@@ -62,7 -58,11 +62,11 @@@ import net.pterodactylus.sone.template.
  import net.pterodactylus.sone.template.SoneAccessor;
  import net.pterodactylus.sone.template.SubstringFilter;
  import net.pterodactylus.sone.template.TrustAccessor;
+ import net.pterodactylus.sone.template.UniqueElementFilter;
  import net.pterodactylus.sone.template.UnknownDateFilter;
+ import net.pterodactylus.sone.text.Part;
+ import net.pterodactylus.sone.text.SonePart;
+ import net.pterodactylus.sone.text.SoneTextParser;
  import net.pterodactylus.sone.web.ajax.BookmarkAjaxPage;
  import net.pterodactylus.sone.web.ajax.CreatePostAjaxPage;
  import net.pterodactylus.sone.web.ajax.CreateReplyAjaxPage;
@@@ -74,6 -74,7 +78,7 @@@ import net.pterodactylus.sone.web.ajax.
  import net.pterodactylus.sone.web.ajax.EditProfileFieldAjaxPage;
  import net.pterodactylus.sone.web.ajax.FollowSoneAjaxPage;
  import net.pterodactylus.sone.web.ajax.GetLikesAjaxPage;
+ import net.pterodactylus.sone.web.ajax.GetNotificationAjaxPage;
  import net.pterodactylus.sone.web.ajax.GetPostAjaxPage;
  import net.pterodactylus.sone.web.ajax.GetReplyAjaxPage;
  import net.pterodactylus.sone.web.ajax.GetStatusAjaxPage;
@@@ -89,22 -90,23 +94,23 @@@ import net.pterodactylus.sone.web.ajax.
  import net.pterodactylus.sone.web.ajax.UnlikeAjaxPage;
  import net.pterodactylus.sone.web.ajax.UnlockSoneAjaxPage;
  import net.pterodactylus.sone.web.ajax.UntrustAjaxPage;
+ import net.pterodactylus.sone.web.page.FreenetRequest;
  import net.pterodactylus.sone.web.page.PageToadlet;
  import net.pterodactylus.sone.web.page.PageToadletFactory;
- import net.pterodactylus.sone.web.page.RedirectPage;
- import net.pterodactylus.sone.web.page.StaticPage;
- import net.pterodactylus.sone.web.page.TemplatePage;
  import net.pterodactylus.util.cache.Cache;
  import net.pterodactylus.util.cache.CacheException;
  import net.pterodactylus.util.cache.CacheItem;
  import net.pterodactylus.util.cache.DefaultCacheItem;
  import net.pterodactylus.util.cache.MemoryCache;
  import net.pterodactylus.util.cache.ValueRetriever;
+ import net.pterodactylus.util.collection.SetBuilder;
+ import net.pterodactylus.util.filter.Filters;
  import net.pterodactylus.util.logging.Logging;
  import net.pterodactylus.util.notify.Notification;
  import net.pterodactylus.util.notify.NotificationManager;
  import net.pterodactylus.util.notify.TemplateNotification;
  import net.pterodactylus.util.template.CollectionSortFilter;
+ import net.pterodactylus.util.template.ContainsFilter;
  import net.pterodactylus.util.template.DateFilter;
  import net.pterodactylus.util.template.FormatFilter;
  import net.pterodactylus.util.template.HtmlFilter;
@@@ -121,10 -123,13 +127,13 @@@ import net.pterodactylus.util.template.
  import net.pterodactylus.util.template.XmlFilter;
  import net.pterodactylus.util.thread.Ticker;
  import net.pterodactylus.util.version.Version;
+ import net.pterodactylus.util.web.RedirectPage;
+ import net.pterodactylus.util.web.StaticPage;
+ import net.pterodactylus.util.web.TemplatePage;
  import freenet.clients.http.SessionManager;
--import freenet.clients.http.SessionManager.Session;
  import freenet.clients.http.ToadletContainer;
  import freenet.clients.http.ToadletContext;
++import freenet.clients.http.SessionManager.Session;
  import freenet.l10n.BaseL10n;
  import freenet.support.api.HTTPRequest;
  
@@@ -154,6 -159,9 +163,9 @@@ public class WebInterface implements Co
        /** The template context factory. */
        private final TemplateContextFactory templateContextFactory;
  
+       /** The Sone text parser. */
+       private final SoneTextParser soneTextParser;
        /** The “new Sone” notification. */
        private final ListNotification<Sone> newSoneNotification;
  
        /** The “new reply” notification. */
        private final ListNotification<Reply> newReplyNotification;
  
-       /** The “rescuing Sone” notification. */
-       private final ListNotification<Sone> rescuingSonesNotification;
+       /** The invisible “local post” notification. */
+       private final ListNotification<Post> localPostNotification;
+       /** The invisible “local reply” notification. */
+       private final ListNotification<Reply> localReplyNotification;
  
-       /** The “Sone rescued” notification. */
-       private final ListNotification<Sone> sonesRescuedNotification;
+       /** The “you have been mentioned” notification. */
+       private final ListNotification<Post> mentionNotification;
+       /** Notifications for sone inserts. */
+       private final Map<Sone, TemplateNotification> soneInsertNotifications = new HashMap<Sone, TemplateNotification>();
  
        /** Sone locked notification ticker objects. */
        private final Map<Sone, Object> lockedSonesTickerObjects = Collections.synchronizedMap(new HashMap<Sone, Object>());
        /** The “new version” notification. */
        private final TemplateNotification newVersionNotification;
  
 +      /** The “inserting images” notification. */
 +      private final ListNotification<Image> insertingImagesNotification;
 +
 +      /** The “inserted images” notification. */
 +      private final ListNotification<Image> insertedImagesNotification;
 +
 +      /** The “image insert failed” notification. */
 +      private final ListNotification<Image> imageInsertFailedNotification;
 +
        /**
         * Creates a new web interface.
         *
        public WebInterface(SonePlugin sonePlugin) {
                this.sonePlugin = sonePlugin;
                formPassword = sonePlugin.pluginRespirator().getToadletContainer().getFormPassword();
+               soneTextParser = new SoneTextParser(getCore(), getCore());
  
                templateContextFactory = new TemplateContextFactory();
                templateContextFactory.addAccessor(Object.class, new ReflectionAccessor());
                templateContextFactory.addAccessor(Sone.class, new SoneAccessor(getCore()));
                templateContextFactory.addAccessor(Post.class, new PostAccessor(getCore()));
                templateContextFactory.addAccessor(Reply.class, new ReplyAccessor(getCore()));
 +              templateContextFactory.addAccessor(Album.class, new AlbumAccessor());
                templateContextFactory.addAccessor(Identity.class, new IdentityAccessor(getCore()));
-               templateContextFactory.addAccessor(NotificationManager.class, new NotificationManagerAccessor());
                templateContextFactory.addAccessor(Trust.class, new TrustAccessor());
                templateContextFactory.addAccessor(HTTPRequest.class, new HttpRequestAccessor());
                templateContextFactory.addFilter("date", new DateFilter());
                templateContextFactory.addFilter("match", new MatchFilter());
                templateContextFactory.addFilter("css", new CssClassNameFilter());
                templateContextFactory.addFilter("js", new JavascriptFilter());
-               templateContextFactory.addFilter("parse", new ParserFilter(getCore(), templateContextFactory));
+               templateContextFactory.addFilter("parse", new ParserFilter(getCore(), templateContextFactory, soneTextParser));
                templateContextFactory.addFilter("unknown", new UnknownDateFilter(getL10n(), "View.Sone.Text.UnknownDate"));
                templateContextFactory.addFilter("format", new FormatFilter());
                templateContextFactory.addFilter("sort", new CollectionSortFilter());
 +              templateContextFactory.addFilter("image-link", new ImageLinkFilter(templateContextFactory));
                templateContextFactory.addFilter("replyGroup", new ReplyGroupFilter());
+               templateContextFactory.addFilter("in", new ContainsFilter());
+               templateContextFactory.addFilter("unique", new UniqueElementFilter());
                templateContextFactory.addProvider(Provider.TEMPLATE_CONTEXT_PROVIDER);
                templateContextFactory.addProvider(new ClassPathTemplateProvider());
+               templateContextFactory.addTemplateObject("webInterface", this);
                templateContextFactory.addTemplateObject("formPassword", formPassword);
  
                /* create notifications. */
                Template newPostNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newPostNotification.html"));
                newPostNotification = new ListNotification<Post>("new-post-notification", "posts", newPostNotificationTemplate, false);
  
+               Template localPostNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newPostNotification.html"));
+               localPostNotification = new ListNotification<Post>("local-post-notification", "posts", localPostNotificationTemplate, false);
                Template newReplyNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newReplyNotification.html"));
-               newReplyNotification = new ListNotification<Reply>("new-replies-notification", "replies", newReplyNotificationTemplate, false);
+               newReplyNotification = new ListNotification<Reply>("new-reply-notification", "replies", newReplyNotificationTemplate, false);
  
-               Template rescuingSonesTemplate = TemplateParser.parse(createReader("/templates/notify/rescuingSonesNotification.html"));
-               rescuingSonesNotification = new ListNotification<Sone>("sones-being-rescued-notification", "sones", rescuingSonesTemplate);
+               Template localReplyNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newReplyNotification.html"));
+               localReplyNotification = new ListNotification<Reply>("local-reply-notification", "replies", localReplyNotificationTemplate, false);
  
-               Template sonesRescuedTemplate = TemplateParser.parse(createReader("/templates/notify/sonesRescuedNotification.html"));
-               sonesRescuedNotification = new ListNotification<Sone>("sones-rescued-notification", "sones", sonesRescuedTemplate);
+               Template mentionNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/mentionNotification.html"));
+               mentionNotification = new ListNotification<Post>("mention-notification", "posts", mentionNotificationTemplate, false);
  
                Template lockedSonesTemplate = TemplateParser.parse(createReader("/templates/notify/lockedSonesNotification.html"));
                lockedSonesNotification = new ListNotification<Sone>("sones-locked-notification", "sones", lockedSonesTemplate);
  
                Template newVersionTemplate = TemplateParser.parse(createReader("/templates/notify/newVersionNotification.html"));
                newVersionNotification = new TemplateNotification("new-version-notification", newVersionTemplate);
 +
 +              Template insertingImagesTemplate = TemplateParser.parse(createReader("/templates/notify/inserting-images-notification.html"));
 +              insertingImagesNotification = new ListNotification<Image>("inserting-images-notification", "images", insertingImagesTemplate);
 +
 +              Template insertedImagesTemplate = TemplateParser.parse(createReader("/templates/notify/inserted-images-notification.html"));
 +              insertedImagesNotification = new ListNotification<Image>("inserted-images-notification", "images", insertedImagesTemplate);
 +
 +              Template imageInsertFailedTemplate = TemplateParser.parse(createReader("/templates/notify/image-insert-failed-notification.html"));
 +              imageInsertFailedNotification = new ListNotification<Image>("image-insert-failed-notification", "images", imageInsertFailedTemplate);
        }
  
        //
         * @return The new posts
         */
        public Set<Post> getNewPosts() {
-               return new HashSet<Post>(newPostNotification.getElements());
+               return new SetBuilder<Post>().addAll(newPostNotification.getElements()).addAll(localPostNotification.getElements()).get();
        }
  
        /**
         * @return The new replies
         */
        public Set<Reply> getNewReplies() {
-               return new HashSet<Reply>(newReplyNotification.getElements());
+               return new SetBuilder<Reply>().addAll(newReplyNotification.getElements()).addAll(localReplyNotification.getElements()).get();
        }
  
        /**
                Template deletePostTemplate = TemplateParser.parse(createReader("/templates/deletePost.html"));
                Template deleteReplyTemplate = TemplateParser.parse(createReader("/templates/deleteReply.html"));
                Template deleteSoneTemplate = TemplateParser.parse(createReader("/templates/deleteSone.html"));
 +              Template imageBrowserTemplate = TemplateParser.parse(createReader("/templates/imageBrowser.html"));
 +              Template createAlbumTemplate = TemplateParser.parse(createReader("/templates/createAlbum.html"));
 +              Template deleteAlbumTemplate = TemplateParser.parse(createReader("/templates/deleteAlbum.html"));
 +              Template deleteImageTemplate = TemplateParser.parse(createReader("/templates/deleteImage.html"));
                Template noPermissionTemplate = TemplateParser.parse(createReader("/templates/noPermission.html"));
                Template optionsTemplate = TemplateParser.parse(createReader("/templates/options.html"));
+               Template rescueTemplate = TemplateParser.parse(createReader("/templates/rescue.html"));
                Template aboutTemplate = TemplateParser.parse(createReader("/templates/about.html"));
                Template invalidTemplate = TemplateParser.parse(createReader("/templates/invalid.html"));
                Template postTemplate = TemplateParser.parse(createReader("/templates/include/viewPost.html"));
                Template openSearchTemplate = TemplateParser.parse(createReader("/templates/xml/OpenSearch.xml"));
  
                PageToadletFactory pageToadletFactory = new PageToadletFactory(sonePlugin.pluginRespirator().getHLSimpleClient(), "/Sone/");
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new RedirectPage("", "index.html")));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new RedirectPage<FreenetRequest>("", "index.html")));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new IndexPage(indexTemplate, this), "Index"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new CreateSonePage(createSoneTemplate, this), "CreateSone"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new KnownSonesPage(knownSonesTemplate, this), "KnownSones"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new UnlockSonePage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new FollowSonePage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new UnfollowSonePage(emptyTemplate, this)));
 +              pageToadlets.add(pageToadletFactory.createPageToadlet(new ImageBrowserPage(imageBrowserTemplate, this), "ImageBrowser"));
 +              pageToadlets.add(pageToadletFactory.createPageToadlet(new CreateAlbumPage(createAlbumTemplate, this)));
 +              pageToadlets.add(pageToadletFactory.createPageToadlet(new EditAlbumPage(emptyTemplate, this)));
 +              pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteAlbumPage(deleteAlbumTemplate, this)));
 +              pageToadlets.add(pageToadletFactory.createPageToadlet(new UploadImagePage(invalidTemplate, this)));
 +              pageToadlets.add(pageToadletFactory.createPageToadlet(new EditImagePage(emptyTemplate, this)));
 +              pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteImagePage(deleteImageTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new TrustPage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DistrustPage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new UntrustPage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new LoginPage(loginTemplate, this), "Login"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new LogoutPage(emptyTemplate, this), "Logout"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new OptionsPage(optionsTemplate, this), "Options"));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new RescuePage(rescueTemplate, this), "Rescue"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new AboutPage(aboutTemplate, this, SonePlugin.VERSION), "About"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new SoneTemplatePage("noPermission.html", noPermissionTemplate, "Page.NoPermission.Title", this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DismissNotificationPage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new SoneTemplatePage("invalid.html", invalidTemplate, "Page.Invalid.Title", this)));
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage("css/", "/static/css/", "text/css")));
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage("javascript/", "/static/javascript/", "text/javascript")));
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage("images/", "/static/images/", "image/png")));
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new TemplatePage("OpenSearch.xml", "application/opensearchdescription+xml", templateContextFactory, openSearchTemplate)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage<FreenetRequest>("css/", "/static/css/", "text/css")));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage<FreenetRequest>("javascript/", "/static/javascript/", "text/javascript")));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage<FreenetRequest>("images/", "/static/images/", "image/png")));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new TemplatePage<FreenetRequest>("OpenSearch.xml", "application/opensearchdescription+xml", templateContextFactory, openSearchTemplate)));
 +              pageToadlets.add(pageToadletFactory.createPageToadlet(new GetImagePage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetTranslationPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetStatusAjaxPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new GetNotificationAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DismissNotificationAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new CreatePostAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new CreateReplyAjaxPage(this)));
                try {
                        return new InputStreamReader(getClass().getResourceAsStream(resourceName), "UTF-8");
                } catch (UnsupportedEncodingException uee1) {
-                       System.out.println("  fail.");
                        return null;
                }
        }
  
-       //
-       // CORELISTENER METHODS
-       //
        /**
-        * {@inheritDoc}
+        * Returns all {@link Core#isLocalSone(Sone) local Sone}s that are
+        * referenced by {@link SonePart}s in the given text (after parsing it using
+        * {@link SoneTextParser}).
+        *
+        * @param text
+        *            The text to parse
+        * @return All mentioned local Sones
         */
-       @Override
-       public void rescuingSone(Sone sone) {
-               rescuingSonesNotification.add(sone);
-               notificationManager.addNotification(rescuingSonesNotification);
+       private Set<Sone> getMentionedSones(String text) {
+               /* we need no context to find mentioned Sones. */
+               Set<Sone> mentionedSones = new HashSet<Sone>();
+               try {
+                       for (Part part : soneTextParser.parse(null, new StringReader(text))) {
+                               if (part instanceof SonePart) {
+                                       mentionedSones.add(((SonePart) part).getSone());
+                               }
+                       }
+               } catch (IOException ioe1) {
+                       logger.log(Level.WARNING, "Could not parse post text: " + text, ioe1);
+               }
+               return Filters.filteredSet(mentionedSones, Sone.LOCAL_SONE_FILTER);
        }
  
        /**
-        * {@inheritDoc}
-        */
-       @Override
-       public void rescuedSone(Sone sone) {
-               rescuingSonesNotification.remove(sone);
-               sonesRescuedNotification.add(sone);
-               notificationManager.addNotification(sonesRescuedNotification);
+        * Returns the Sone insert notification for the given Sone. If no
+        * notification for the given Sone exists, a new notification is created and
+        * cached.
+        *
+        * @param sone
+        *            The Sone to get the insert notification for
+        * @return The Sone insert notification
+        */
+       private TemplateNotification getSoneInsertNotification(Sone sone) {
+               synchronized (soneInsertNotifications) {
+                       TemplateNotification templateNotification = soneInsertNotifications.get(sone);
+                       if (templateNotification == null) {
+                               templateNotification = new TemplateNotification(TemplateParser.parse(createReader("/templates/notify/soneInsertNotification.html")));
+                               templateNotification.set("insertSone", sone);
+                               soneInsertNotifications.put(sone, templateNotification);
+                       }
+                       return templateNotification;
+               }
        }
  
+       //
+       // CORELISTENER METHODS
+       //
        /**
         * {@inheritDoc}
         */
         */
        @Override
        public void newPostFound(Post post) {
-               newPostNotification.add(post);
+               boolean isLocal = getCore().isLocalSone(post.getSone());
+               if (isLocal) {
+                       localPostNotification.add(post);
+               } else {
+                       newPostNotification.add(post);
+               }
                if (!hasFirstStartNotification()) {
-                       notificationManager.addNotification(newPostNotification);
+                       notificationManager.addNotification(isLocal ? localPostNotification : newPostNotification);
+                       if (!getMentionedSones(post.getText()).isEmpty() && !isLocal) {
+                               mentionNotification.add(post);
+                               notificationManager.addNotification(mentionNotification);
+                       }
                } else {
                        getCore().markPostKnown(post);
                }
         */
        @Override
        public void newReplyFound(Reply reply) {
-               if (reply.getPost().getSone() == null) {
-                       return;
+               boolean isLocal = getCore().isLocalSone(reply.getSone());
+               if (isLocal) {
+                       localReplyNotification.add(reply);
+               } else {
+                       newReplyNotification.add(reply);
                }
-               newReplyNotification.add(reply);
                if (!hasFirstStartNotification()) {
-                       notificationManager.addNotification(newReplyNotification);
+                       notificationManager.addNotification(isLocal ? localReplyNotification : newReplyNotification);
+                       if (!getMentionedSones(reply.getText()).isEmpty() && !isLocal && (reply.getPost().getSone() != null)) {
+                               mentionNotification.add(reply.getPost());
+                               notificationManager.addNotification(mentionNotification);
+                       }
                } else {
                        getCore().markReplyKnown(reply);
                }
        @Override
        public void markPostKnown(Post post) {
                newPostNotification.remove(post);
+               localPostNotification.remove(post);
+               mentionNotification.remove(post);
        }
  
        /**
        @Override
        public void markReplyKnown(Reply reply) {
                newReplyNotification.remove(reply);
+               localReplyNotification.remove(reply);
+               mentionNotification.remove(reply.getPost());
+       }
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public void soneRemoved(Sone sone) {
+               newSoneNotification.remove(sone);
        }
  
        /**
        @Override
        public void postRemoved(Post post) {
                newPostNotification.remove(post);
+               localPostNotification.remove(post);
        }
  
        /**
        @Override
        public void replyRemoved(Reply reply) {
                newReplyNotification.remove(reply);
+               localReplyNotification.remove(reply);
        }
  
        /**
         * {@inheritDoc}
         */
        @Override
+       public void soneInserting(Sone sone) {
+               TemplateNotification soneInsertNotification = getSoneInsertNotification(sone);
+               soneInsertNotification.set("soneStatus", "inserting");
+               if (sone.getOptions().getBooleanOption("EnableSoneInsertNotifications").get()) {
+                       notificationManager.addNotification(soneInsertNotification);
+               }
+       }
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public void soneInserted(Sone sone, long insertDuration) {
+               TemplateNotification soneInsertNotification = getSoneInsertNotification(sone);
+               soneInsertNotification.set("soneStatus", "inserted");
+               soneInsertNotification.set("insertDuration", insertDuration / 1000);
+               if (sone.getOptions().getBooleanOption("EnableSoneInsertNotifications").get()) {
+                       notificationManager.addNotification(soneInsertNotification);
+               }
+       }
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public void soneInsertAborted(Sone sone, Throwable cause) {
+               TemplateNotification soneInsertNotification = getSoneInsertNotification(sone);
+               soneInsertNotification.set("soneStatus", "insert-aborted");
+               soneInsertNotification.set("insert-error", cause);
+               if (sone.getOptions().getBooleanOption("EnableSoneInsertNotifications").get()) {
+                       notificationManager.addNotification(soneInsertNotification);
+               }
+       }
+       /**
+        * {@inheritDoc}
+        */
+       @Override
        public void updateFound(Version version, long releaseTime, long latestEdition) {
                newVersionNotification.getTemplateContext().set("latestVersion", version);
                newVersionNotification.getTemplateContext().set("latestEdition", latestEdition);
        }
  
        /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public void imageInsertStarted(Image image) {
 +              insertingImagesNotification.add(image);
 +              notificationManager.addNotification(insertingImagesNotification);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public void imageInsertAborted(Image image) {
 +              insertingImagesNotification.remove(image);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public void imageInsertFinished(Image image) {
 +              insertingImagesNotification.remove(image);
 +              insertedImagesNotification.add(image);
 +              notificationManager.addNotification(insertedImagesNotification);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public void imageInsertFailed(Image image, Throwable cause) {
 +              insertingImagesNotification.remove(image);
 +              imageInsertFailedNotification.add(image);
 +              notificationManager.addNotification(imageInsertFailedNotification);
 +      }
 +
 +      /**
         * Template provider implementation that uses
         * {@link WebInterface#createReader(String)} to load templates for
         * inclusion.
@@@ -12,14 -12,14 +12,16 @@@ Navigation.Menu.Item.Bookmarks.Name=Boo
  Navigation.Menu.Item.Bookmarks.Tooltip=Show bookmarked posts
  Navigation.Menu.Item.EditProfile.Name=Edit Profile
  Navigation.Menu.Item.EditProfile.Tooltip=Edit the Profile of your Sone
 +Navigation.Menu.Item.ImageBrowser.Name=Images
 +Navigation.Menu.Item.ImageBrowser.Tooltip=Manages your Images
  Navigation.Menu.Item.DeleteSone.Name=Delete Sone
  Navigation.Menu.Item.DeleteSone.Tooltip=Deletes the current Sone
  Navigation.Menu.Item.Logout.Name=Logout
  Navigation.Menu.Item.Logout.Tooltip=Logs you out of the current Sone
  Navigation.Menu.Item.Options.Name=Options
  Navigation.Menu.Item.Options.Tooltip=Options for the Sone plugin
+ Navigation.Menu.Item.Rescue.Name=Rescue
+ Navigation.Menu.Item.Rescue.Tooltip=Rescue Sone
  Navigation.Menu.Item.About.Name=About
  Navigation.Menu.Item.About.Tooltip=Information about Sone
  
@@@ -36,19 -36,27 +38,27 @@@ Page.Options.Page.Description=These opt
  Page.Options.Section.SoneSpecificOptions.Title=Sone-specific Options
  Page.Options.Section.SoneSpecificOptions.NotLoggedIn=These options are only available if you are {link}logged in{/link}.
  Page.Options.Section.SoneSpecificOptions.LoggedIn=These options are only available while you are logged in and they are only valid for the Sone you are logged in as.
- Page.Options.Option.AutoFollow.Description=If a new Sone is discovered, follow it automatically.
+ Page.Options.Option.AutoFollow.Description=If a new Sone is discovered, follow it automatically. Note that this will only follow Sones that are discovered after you activate this option!
+ Page.Options.Option.EnableSoneInsertNotifications.Description=If enabled, this will display notifications every time your Sone is being inserted or finishes inserting.
  Page.Options.Section.RuntimeOptions.Title=Runtime Behaviour
  Page.Options.Option.InsertionDelay.Description=The number of seconds the Sone inserter waits after a modification of a Sone before it is being inserted.
  Page.Options.Option.PostsPerPage.Description=The number of posts to display on a page before pagination controls are being shown.
+ Page.Options.Option.CharactersPerPost.Description=The number of characters to display from a post before cutting it off and showing a link to expand it (-1 to disable).
+ Page.Options.Option.RequireFullAccess.Description=Whether to deny access to Sone to any host that has not been granted full access.
  Page.Options.Section.TrustOptions.Title=Trust Settings
  Page.Options.Option.PositiveTrust.Description=The amount of positive trust you want to assign to other Sones by clicking the checkmark below a post or reply.
  Page.Options.Option.NegativeTrust.Description=The amount of trust you want to assign to other Sones by clicking the red X below a post or reply. This value should be negative.
  Page.Options.Option.TrustComment.Description=The comment that will be set in the web of trust for any trust you assign from Sone.
- Page.Options.Section.RescueOptions.Title=Rescue Settings
- Page.Options.Option.SoneRescueMode.Description=Try to rescue your Sones at the next start of the Sone plugin. This will read your all your old Sones from Freenet and ignore any disappearing postings and replies. You have to unlock your local Sones after they have been restored and you have to manually disable the rescue mode once you are satisfied with what has been restored!
+ Page.Options.Section.FcpOptions.Title=FCP Interface Settings
+ Page.Options.Option.FcpInterfaceActive.Description=Activate the FCP interface to allow other plugins and remote clients to access your Sone plugin.
+ Page.Options.Option.FcpFullAccessRequired.Description=Require FCP connection from allowed hosts (see your {link}node’s configuration, section “FCP”{/link})
+ Page.Options.Option.FcpFullAccessRequired.Value.No=No
+ Page.Options.Option.FcpFullAccessRequired.Value.Writing=For Write Access
+ Page.Options.Option.FcpFullAccessRequired.Value.Always=Always
  Page.Options.Section.Cleaning.Title=Clean Up
  Page.Options.Option.ClearOnNextRestart.Description=Resets the configuration of the Sone plugin at the next restart. Warning! {strong}This will destroy all of your Sones{/strong} so make sure you have backed up everyhing you still need! Also, you need to set the next option to true to actually do it.
  Page.Options.Option.ReallyClearOnNextRestart.Description=This option needs to be set to “yes” if you really, {strong}really{/strong} want to clear the plugin configuration on the next restart.
+ Page.Options.Warnings.ValueNotChanged=This option was not changed because the value you specified was not valid.
  Page.Options.Button.Save=Save
  
  Page.Login.Title=Login - Sone
@@@ -76,6 -84,8 +86,8 @@@ Page.Index.PostList.Text.NoPostYet=Nobo
  Page.KnownSones.Title=Known Sones - Sone
  Page.KnownSones.Page.Title=Known Sones
  Page.KnownSones.Text.NoKnownSones=There are currently no known Sones.
+ Page.KnownSones.Button.FollowAllSones=Follow all Sones
+ Page.KnownSones.Button.UnfollowAllSones=Unfollow all Sones
  
  Page.EditProfile.Title=Edit Profile - Sone
  Page.EditProfile.Page.Title=Edit Profile
@@@ -131,13 -141,14 +143,15 @@@ Page.ViewSone.Title=View Sone - Son
  Page.ViewSone.Page.TitleWithoutSone=View unknown Sone
  Page.ViewSone.NoSone.Description=There is currently no known Sone with the ID {sone}. If you were looking for a specific Sone, make sure that it is visible in your web of trust!
  Page.ViewSone.UnknownSone.Description=This Sone has not yet been retrieved. Please check back in a short time.
+ Page.ViewSone.UnknownSone.LinkToWebOfTrust=Even though the Sone is still unknown, its Web of Trust profile might already be available:
  Page.ViewSone.WriteAMessage=You can write a message to this Sone here. Please note that everybody will be able to read this message!
  Page.ViewSone.PostList.Title=Posts by {sone}
  Page.ViewSone.PostList.Text.NoPostYet=This Sone has not yet posted anything.
  Page.ViewSone.Profile.Title=Profile
  Page.ViewSone.Profile.Label.Name=Name
- Page.ViewSone.Replies.Title=Replies to Posts
 +Page.ViewSone.Profile.Label.Albums=Albums
+ Page.ViewSone.Profile.Name.WoTLink=web of trust profile
+ Page.ViewSone.Replies.Title=Posts {sone} has replied to
  
  Page.ViewPost.Title=View Post - Sone
  Page.ViewPost.Page.Title=View Post by {sone}
@@@ -167,47 -178,6 +181,47 @@@ Page.FollowSone.Title=Follow Sone - Son
  
  Page.UnfollowSone.Title=Unfollow Sone - Sone
  
 +Page.ImageBrowser.Title=Image Browser - Sone
 +Page.ImageBrowser.Album.Title=Album “{album}”
 +Page.ImageBrowser.Album.Error.NotFound.Text=The requested album could not be found. It is possible that it has not yet been downloaded, or that it has been deleted.
 +Page.ImageBrowser.Sone.Title=Albums of {sone}
 +Page.ImageBrowser.Sone.Error.NotFound.Text=The requested Sone could not be found. It is possible that it has not yet been downloaded.
 +Page.ImageBrowser.CreateAlbum.Button.CreateAlbum=Create Album
 +Page.ImageBrowser.Album.Edit.Title=Edit Album
 +Page.ImageBrowser.Album.Label.Title=Title:
 +Page.ImageBrowser.Album.Label.Description=Description:
 +Page.ImageBrowser.Album.Button.Save=Save Album
 +Page.ImageBrowser.Album.Button.Delete=Delete Album
 +Page.ImageBrowser.Image.Edit.Title=Edit Image
 +Page.ImageBrowser.Image.Title.Label=Title:
 +Page.ImageBrowser.Image.Description.Label=Description:
 +Page.ImageBrowser.Image.Button.Save=Save Image
 +Page.ImageBrowser.Image.Delete.Title=Delete Image
 +Page.ImageBrowser.Image.Button.Delete=Delete Image
 +
 +Page.CreateAlbum.Title=Create Album - Sone
 +Page.CreateAlbum.Page.Title=Create Album
 +Page.CreateAlbum.Error.NameMissing=You seem to have forgotten to enter a name for your new album.
 +
 +Page.UploadImage.Title=Upload Image - Sone
 +Page.UploadImage.Error.InvalidImage=The image you were trying to upload could not be recognized. Please upload only JPEG (*.jpg or *.jpeg), or PNG (*.png) images.
 +
 +Page.EditImage.Title=Edit Image
 +
 +Page.DeleteImage.Title=Delete Image
 +Page.DeleteImage.Page.Title=Delete Image
 +Page.DeleteImage.Text.ImageWillBeGone=This will remove the image “{image}” from your album “{album}”. If it has already been inserted into Freenet it can not be removed from there forcefully. Do you want to do delete the image?
 +Page.DeleteImage.Button.Yes=Yes, delete image.
 +Page.DeleteImage.Button.No=No, don’t delete image.
 +
 +Page.EditAlbum.Title=Edit Album
 +
 +Page.DeleteAlbum.Title=Delete Album
 +Page.DeleteAlbum.Page.Title=Delete Album
 +Page.DeleteAlbum.Text.AlbumWillBeGone=This will remove your album “{title}”. Do you really want to do that?
 +Page.DeleteAlbum.Button.Yes=Yes, delete album.
 +Page.DeleteAlbum.Button.No=No, don’t delete album.
 +
  Page.Trust.Title=Trust Sone - Sone
  
  Page.Distrust.Title=Distrust Sone - Sone
@@@ -229,6 -199,17 +243,17 @@@ Page.Search.Text.SoneHits=The followin
  Page.Search.Text.PostHits=The following posts match your search terms.
  Page.Search.Text.NoHits=No Sones or posts matched your search terms.
  
+ Page.Rescue.Title=Rescue Sone
+ Page.Rescue.Page.Title=Rescue Sone “{0}”
+ Page.Rescue.Text.Description=The Rescue Mode lets you restore previous versions of your Sone. This can be necessary if your configuration was lost.
+ Page.Rescue.Text.Procedure=The Rescue Mode works by fetching the latest inserted edition of your Sone. If an edition was successfully fetched it will be loaded into your Sone, letting you control your posts, profile, and other settings (you could do that in a second browser tab or window). If the fetched edition is not the one you want to restore, instruct the Rescue Mode to fetch the next older edition below.
+ Page.Rescue.Text.Fetching=The Sone Rescuer is currently fetching edition {0} of your Sone.
+ Page.Rescue.Text.Fetched=The Sone Rescuer has downloaded edition {0} of your Sone. Please check your posts, replies, and profile. If you like what the current Sone contains, just unlock it.
+ Page.Rescue.Text.FetchedLast=The Sone rescuer has downloaded the last available edition. If it did not manage to restore your Sone you are probably out of luck now.
+ Page.Rescue.Text.NotFetched=The Sone Rescuer could not download edition {0} of your Sone. Please either try again with edition {0}, or try the next older edition.
+ Page.Rescue.Label.NextEdition=Next edition:
+ Page.Rescue.Button.Fetch=Fetch edition
  Page.NoPermission.Title=Unauthorized Access - Sone
  Page.NoPermission.Page.Title=Unauthorized Access
  Page.NoPermission.Text.NoPermission=You tried to do something that you do not have sufficient authorization for. Please refrain from such actions in the future or we will be forced to take counter-measures!
@@@ -255,6 -236,8 +280,8 @@@ View.CreateSone.Text.Error.NoIdentity=Y
  
  View.Sone.Label.LastUpdate=Last update:
  View.Sone.Text.UnknownDate=unknown
+ View.Sone.Stats.Posts={0,number} {0,choice,0#posts|1#post|1<posts}
+ View.Sone.Stats.Replies={0,number} {0,choice,0#replies|1#reply|1<replies}
  View.Sone.Button.UnlockSone=unlock
  View.Sone.Button.UnlockSone.Tooltip=Allow this Sone to be inserted now
  View.Sone.Button.LockSone=lock
@@@ -268,6 -251,9 +295,9 @@@ View.Sone.Status.Downloading=This Sone 
  View.Sone.Status.Inserting=This Sone is currently being inserted.
  
  View.Post.UnknownAuthor=(unknown)
+ View.Post.WebOfTrustLink=web of trust profile
+ View.Post.Permalink=link post
+ View.Post.PermalinkAuthor=link author
  View.Post.Bookmarks.PostIsBookmarked=Post is bookmarked, click to remove from bookmarks
  View.Post.Bookmarks.PostIsNotBookmarked=Post is not bookmarked, click to bookmark
  View.Post.DeleteLink=Delete
@@@ -277,6 -263,8 +307,8 @@@ View.Post.LikeLink=Lik
  View.Post.UnlikeLink=Unlike
  View.Post.ShowSource=Toggle Parser
  View.Post.NotDownloaded=This post has not yet been downloaded, or it has been deleted.
+ View.Post.ShowMore=show more
+ View.Post.ShowLess=show less
  
  View.UpdateStatus.Text.ChooseSenderIdentity=Choose the sender identity
  
@@@ -284,15 -272,6 +316,15 @@@ View.Trust.Tooltip.Trust=Trust this per
  View.Trust.Tooltip.Distrust=Assign negative trust to this person
  View.Trust.Tooltip.Untrust=Remove your trust assignment for this person
  
 +View.CreateAlbum.Title=Create Album
 +View.CreateAlbum.Label.Name=Name:
 +View.CreateAlbum.Label.Description=Description:
 +
 +View.UploadImage.Title=Upload Image
 +View.UploadImage.Label.Title=Title:
 +View.UploadImage.Label.Description=Description:
 +View.UploadImage.Button.UploadImage=Upload Image
 +
  View.Time.InTheFuture=in the future
  View.Time.AFewSecondsAgo=a few seconds ago
  View.Time.HalfAMinuteAgo=about half a minute ago
@@@ -304,7 -283,7 +336,7 @@@ View.Time.XHoursAgo=${hour} hours ag
  View.Time.ADayAgo=about a day ago
  View.Time.XDaysAgo=${day} days ago
  View.Time.AWeekAgo=about a week ago
- View.Time.XWeeksAgo=${week} week ago
+ View.Time.XWeeksAgo=${week} weeks ago
  View.Time.AMonthAgo=about a month ago
  View.Time.XMonthsAgo=${month} months ago
  View.Time.AYearAgo=about a year ago
@@@ -321,19 -300,12 +353,20 @@@ WebInterface.DefaultText.BirthMonth=Mon
  WebInterface.DefaultText.BirthYear=Year
  WebInterface.DefaultText.FieldName=Field name
  WebInterface.DefaultText.Option.InsertionDelay=Time to wait after a Sone is modified before insert (in seconds)
 +WebInterface.DefaultText.Search=What are you looking for?
 +WebInterface.DefaultText.CreateAlbum.Name=Album title
 +WebInterface.DefaultText.CreateAlbum.Description=Album description
 +WebInterface.DefaultText.EditAlbum.Title=Album title
 +WebInterface.DefaultText.EditAlbum.Description=Album description
 +WebInterface.DefaultText.UploadImage.Title=Image title
 +WebInterface.DefaultText.UploadImage.Description=Image description
 +WebInterface.DefaultText.EditImage.Title=Image title
 +WebInterface.DefaultText.EditImage.Description=Image description
  WebInterface.DefaultText.Option.PostsPerPage=Number of posts to show on a page
+ WebInterface.DefaultText.Option.CharactersPerPost=Number of characters per post after which to cut the post off
  WebInterface.DefaultText.Option.PositiveTrust=The positive trust to assign
  WebInterface.DefaultText.Option.NegativeTrust=The negative trust to assign
  WebInterface.DefaultText.Option.TrustComment=The comment to set in the web of trust
 -WebInterface.DefaultText.Search=What are you looking for?
  WebInterface.Confirmation.DeletePostButton=Yes, delete!
  WebInterface.Confirmation.DeleteReplyButton=Yes, delete!
  WebInterface.SelectBox.Choose=Choose…
@@@ -360,6 -332,6 +393,9 @@@ Notification.SoneRescued.Text=The follo
  Notification.SoneRescued.Text.RememberToUnlock=Please remember to control the posts and replies you have given and don’t forget to unlock your Sones!
  Notification.LockedSones.Text=The following Sones have been locked for more than 5 minutes. Please check if you really want to keep these Sones locked:
  Notification.NewVersion.Text=Version {version} of the Sone plugin was found. Download it from USK@nwa8lHa271k2QvJ8aa0Ov7IHAV-DFOCFgmDt3X6BpCI,DuQSUZiI~agF8c-6tjsFFGuZ8eICrzWCILB60nT8KKo,AQACAAE/sone/{edition}​!
 +Notification.InsertingImages.Text=The following images are being inserted:
 +Notification.InsertedImages.Text=The following images have been inserted:
 +Notification.ImageInsertFailed.Text=The following images could not be inserted:
+ Notification.Mention.ShortText=You have been mentioned.
+ Notification.Mention.Text=You have been mentioned in the following posts:
+ Notification.SoneInsert.Duration={0,number} {0,choice,0#seconds|1#second|1<seconds}
@@@ -89,14 -89,28 +89,32 @@@ textarea 
        content: '★ ';
  }
  
+ #sone a.in-page-link:before {
+       content: '↓ ';
+ }
  #sone a img {
        border: none;
  }
  
 +#sone .parsed {
 +      white-space: pre-wrap;
 +}
 +
+ #sone #main.offline {
+       opacity: 0.5;
+ }
+ #sone #offline-marker {
+       display: none;
+       position: fixed;
+       top: 2em;
+       right: 2em;
+       width: 128px;
+       height: 128px;
+       background-image: url("../images/sone-offline.png");
+ }
  #sone #notification-area {
        margin-top: 1em;
  }
        display: none;
  }
  
+ #sone #notification-area #local-post-notification, #sone #notification-area #local-reply-notification {
+       display: none;
+ }
  #sone #plugin-warning {
        border: solid 0.5em red;
        padding: 0.5em;
        padding: 1ex 0px;
        border-bottom: solid 1px #ccc;
        clear: both;
+       position: relative;
  }
  
  #sone .post.new {
        border-bottom: none;
  }
  
+ #sone .post .sone-menu {
+       position: absolute;
+       top: 0;
+       left: -1ex;
+       padding: 1ex 1ex;
+       margin: -1px -1px;
+       display: none;
+       background-color: rgb(255, 255, 224);
+       border: solid 1px rgb(0, 0, 0);
+       z-index: 1;
+ }
+ #sone .post .sone-menu .avatar {
+       position: absolute;
+       margin-right: 1ex;
+ }
+ #sone .post .sone-menu .inner-menu {
+       margin-left: 64px;
+       padding-left: 1ex;
+       min-height: 64px;
+ }
+ #sone .sone-menu .follow, #sone .sone-menu .unfollow {
+       cursor: pointer;
+ }
  #sone .post > .avatar {
        position: absolute;
  }
        font-weight: bold;
  }
  
- #sone .post .text, #sone .post .raw-text {
+ #sone .post .author-wot-link {
+       font-size: 90%;
+ }
+ #sone .post .text, #sone .post .raw-text, #sone .post .short-text {
        display: inline;
        white-space: pre-wrap;
+       word-wrap: break-word;
  }
  
- #sone .post .text.hidden, #sone .post .raw-text.hidden {
+ #sone .post .text.hidden, #sone .post .raw-text.hidden, #sone .post .short-text.hidden {
        display: none;
  }
  
+ #sone .post .expand-post-text:before, #sone .post .expand-reply-text:before {
+       content: "» ";
+ }
+ #sone .post .shrink-post-text:before, #sone .post .shrink-reply-text:before {
+       content: "« ";
+ }
+ #sone .post .shrink-post-text {
+       cursor: pointer;
+ }
  #sone .post .status-line {
        margin-top: 0.5ex;
        font-size: 85%;
        display: inline;
  }
  
+ #sone .permalink {
+       display: inline;
+ }
  #sone .post .bookmarks {
        display: inline;
        color: rgb(28, 131, 191);
  }
  
  #sone .post .reply {
+       position: relative;
        clear: both;
        background-color: #f0f0ff;
-       font-size: 85%;
        margin: 1ex 0px;
        padding: 1ex;
  }
  
+ #sone .post .reply .inner-part {
+       font-size: 85%;
+ }
  #sone .post .reply.new {
        background-color: #ffffa0;
  }
  }
  
  #sone .sone .profile-link {
-       display: block;
+       display: inline;
+ }
+ #sone .sone .sone-stats {
+       display: inline;
  }
  
  #sone .sone .short-request-uri {
        position: relative;
  }
  
 +#sone .backlinks {
 +      font-size: 80%;
 +}
 +
 +#sone .backlinks .backlink {
 +      display: inline;
 +}
 +
 +#sone .album {
 +}
 +
 +#sone .image {
 +      width: 200px;
 +      height: 150px;
 +      display: table-cell;
 +      vertical-align: middle;
 +      text-align: center;
 +      padding: 0.5ex;
 +}
 +
 +#sone .image img, #sone .single-image img {
 +      padding: 1ex;
 +      border: solid 1px #000;
 +      background-color: #fff;
 +}
 +
 +#sone .backlinks .separator {
 +      display: inline;
 +}
 +
  #sone #search {
        text-align: right;
  }
        font-weight: bold;
  }
  
 -#sone input.default {
 +#sone input.default, #sone textarea.default {
        color: #888;
  }
  
        font-weight: bold;
        color: red;
  }
+ #sone .warning {
+       color: red;
+       font-style: italic;
+ }
+ #sone #sort-options {
+       margin-bottom: 1em;
+ }
@@@ -7,13 -7,17 +7,17 @@@
  
                <h1><%= Page.ViewSone.Page.TitleWithoutSone|l10n|html></h1>
  
-               <p><%= Page.ViewSone.NoSone.Description|l10n|replace needle="{sone}" replacementKey=sone.id|html></p>
+               <p><%= Page.ViewSone.NoSone.Description|l10n|replace needle="{sone}" replacementKey=soneId|html></p>
  
        <%elseifnull sone.name>
  
                <h1><%= Page.ViewSone.Page.TitleWithoutSone|l10n|html></h1>
  
                <p><%= Page.ViewSone.UnknownSone.Description|l10n|html></p>
+               <p>
+                       <%= Page.ViewSone.UnknownSone.LinkToWebOfTrust|l10n|html>
+                       <a href="/WebOfTrust/ShowIdentity?id=<% sone.id|html>"><%= Page.ViewSone.Profile.Name.WoTLink|l10n|html></a>
+               </p>
  
        <%else>
  
  
                        <div class="profile-field">
                                <div class="name"><%= Page.ViewSone.Profile.Label.Name|l10n|html></div>
-                               <div class="value"><a href="/WebOfTrust/ShowIdentity?id=<% sone.id|html>"><% sone.niceName|html></a></div>
+                               <div class="value"><% sone.niceName|html> (<a href="/WebOfTrust/ShowIdentity?id=<% sone.id|html>"><%= Page.ViewSone.Profile.Name.WoTLink|l10n|html></a>)</div>
                        </div>
  
 +                      <%foreach sone.albums album>
 +                              <%first>
 +                                      <div class="profile-field">
 +                                              <div class="name"><%= Page.ViewSone.Profile.Label.Albums|l10n|html></div>
 +                                              <div class="value">
 +                              <%/first>
 +                                      <a href="imageBrowser.html?album=<%album.id|html>"><%album.title|html></a><%notlast>, <%/notlast>
 +                              <%last>
 +                                              </div>
 +                                      </div>
 +                              <%/last>
 +                      <%/foreach>
 +
                        <%foreach sone.profile.fields field>
                                <div class="profile-field">
                                        <div class="name"><% field.name|html></div>
@@@ -76,7 -67,7 +80,7 @@@
                <%foreach posts post>
                        <%first>
                                <div id="posts">
-                                       <%include include/pagination.html pagination=postPagination pageParameter==postPage>
+                                       <%include include/pagination.html pagination=postPagination pageParameter==postPage paginationName==post-navigation>
                        <%/first>
                        <%include include/viewPost.html>
                        <%last>
@@@ -89,9 -80,9 +93,9 @@@
  
                <%foreach repliedPosts post>
                        <%first>
-                               <h2><%= Page.ViewSone.Replies.Title|l10n|html></h2>
+                               <h2><%= Page.ViewSone.Replies.Title|l10n|html|replace needle="{sone}" replacementKey=sone.niceName></h2>
                                <div id="replied-posts">
-                                       <%include include/pagination.html pagination=repliedPostPagination pageParameter==repliedPostPage>
+                                       <%include include/pagination.html pagination=repliedPostPagination pageParameter==repliedPostPage paginationName==reply-navigation>
                        <%/first>
                        <%include include/viewPost.html>
                        <%last>