Bring image-management up to speed.
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Wed, 13 Apr 2011 04:46:32 +0000 (06:46 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Wed, 13 Apr 2011 04:46:32 +0000 (06:46 +0200)
Conflicts:
src/main/java/net/pterodactylus/sone/core/Core.java
src/main/java/net/pterodactylus/sone/data/Sone.java
src/main/java/net/pterodactylus/sone/web/WebInterface.java
src/main/resources/i18n/sone.en.properties
src/main/resources/static/css/sone.css

1  2 
src/main/java/net/pterodactylus/sone/core/Core.java
src/main/java/net/pterodactylus/sone/data/Sone.java
src/main/java/net/pterodactylus/sone/text/FreenetLinkParser.java
src/main/java/net/pterodactylus/sone/web/WebInterface.java
src/main/resources/i18n/sone.en.properties
src/main/resources/static/css/sone.css

@@@ -31,15 -31,12 +31,15 @@@ 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.freenet.wot.Identity;
  import net.pterodactylus.sone.freenet.wot.IdentityListener;
  import net.pterodactylus.sone.freenet.wot.IdentityManager;
@@@ -60,7 -57,7 +60,7 @@@ import freenet.keys.FreenetURI
   *
   * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
   */
 -public class Core implements IdentityListener, UpdateListener {
 +public class Core implements IdentityListener, UpdateListener, ImageInsertListener {
  
        /**
         * Enumeration for the possible states of a {@link Sone}.
        /** The Sone downloader. */
        private final SoneDownloader soneDownloader;
  
 +      /** The image inserter. */
 +      private final ImageInserter imageInserter;
 +
        /** The update checker. */
        private final UpdateChecker updateChecker;
  
        /** 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>();
 +
        /**
         * Creates a new core.
         *
                this.freenetInterface = freenetInterface;
                this.identityManager = identityManager;
                this.soneDownloader = new SoneDownloader(this, freenetInterface);
 +              this.imageInserter = new ImageInserter(this, freenetInterface);
                this.updateChecker = new UpdateChecker(freenetInterface);
        }
  
         * @return {@code true} if the target Sone is trusted by the origin Sone
         */
        public boolean isSoneTrusted(Sone origin, Sone target) {
-               return trustedIdentities.containsKey(origin) && trustedIdentities.get(origin.getIdentity()).contains(target);
+               Validation.begin().isNotNull("Origin", origin).isNotNull("Target", target).check().isInstanceOf("Origin’s OwnIdentity", origin.getIdentity(), OwnIdentity.class).check();
+               return trustedIdentities.containsKey(origin.getIdentity()) && trustedIdentities.get(origin.getIdentity()).contains(target.getIdentity());
        }
  
        /**
         *
         * @param postId
         *            The ID of the post to get
-        * @return The post, or {@code null} if there is no such post
+        * @return The post with the given ID, or a new post with the given ID
         */
        public Post getPost(String postId) {
                return getPost(postId, true);
        }
  
        /**
+        * Returns all posts that have the given Sone as recipient.
+        *
+        * @see Post#getRecipient()
+        * @param recipient
+        *            The recipient of the posts
+        * @return All posts that have the given Sone as recipient
+        */
+       public Set<Post> getDirectedPosts(Sone recipient) {
+               Validation.begin().isNotNull("Recipient", recipient).check();
+               Set<Post> directedPosts = new HashSet<Post>();
+               synchronized (posts) {
+                       for (Post post : posts.values()) {
+                               if (recipient.equals(post.getRecipient())) {
+                                       directedPosts.add(post);
+                               }
+                       }
+               }
+               return directedPosts;
+       }
+       /**
         * Returns the reply with the given ID. If there is no reply with the given
         * ID yet, a new one is created.
         *
                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
        //
                                @SuppressWarnings("synthetic-access")
                                public void run() {
                                        if (!preferences.isSoneRescueMode()) {
-                                               soneDownloader.fetchSone(sone);
                                                return;
                                        }
                                        logger.log(Level.INFO, "Trying to restore Sone from Freenet…");
                        return null;
                }
                Sone sone = addLocalSone(ownIdentity);
+               sone.getOptions().addBooleanOption("AutoFollow", new DefaultOption<Boolean>(false));
+               saveSone(sone);
                return sone;
        }
  
                                }
                                if (newSone) {
                                        coreListenerManager.fireNewSoneFound(sone);
+                                       for (Sone localSone : getLocalSones()) {
+                                               if (localSone.getOptions().getBooleanOption("AutoFollow").get()) {
+                                                       localSone.addFriend(sone.getId());
+                                               }
+                                       }
                                }
                        }
                        remoteSones.put(identity.getId(), sone);
                        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.setLikePostIds(likedPostIds);
                        sone.setLikeReplyIds(likedReplyIds);
                        sone.setFriends(friends);
 +                      sone.setAlbums(topLevelAlbums);
                        soneInserters.get(sone).setLastInsertFingerprint(lastInsertFingerprint);
                }
                synchronized (newSones) {
                        }
                        configuration.getStringValue(sonePrefix + "/Friends/" + friendCounter + "/ID").setValue(null);
  
 +                      /* save albums. first, collect in a flat structure, top-level first. */
 +                      List<Album> albums = new ArrayList<Album>();
 +                      albums.addAll(sone.getAlbums());
 +                      int lastAlbumIndex = 0;
 +                      while (lastAlbumIndex < albums.size()) {
 +                              int previousAlbumCount = albums.size();
 +                              for (Album album : new ArrayList<Album>(albums.subList(lastAlbumIndex, albums.size()))) {
 +                                      albums.addAll(album.getAlbums());
 +                              }
 +                              lastAlbumIndex = previousAlbumCount;
 +                      }
 +
 +                      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());
  
                        posts.put(post.getId(), post);
                }
                synchronized (newPosts) {
-                       knownPosts.add(post.getId());
+                       newPosts.add(post.getId());
+                       coreListenerManager.fireNewPostFound(post);
                }
                sone.addPost(post);
                saveSone(sone);
                synchronized (posts) {
                        posts.remove(post.getId());
                }
+               synchronized (newPosts) {
+                       markPostKnown(post);
+                       knownPosts.remove(post.getId());
+               }
                saveSone(post.getSone());
        }
  
                        replies.put(reply.getId(), reply);
                }
                synchronized (newReplies) {
-                       knownReplies.add(reply.getId());
+                       newReplies.add(reply.getId());
+                       coreListenerManager.fireNewReplyFound(reply);
                }
                sone.addReply(reply);
                saveSone(sone);
                synchronized (replies) {
                        replies.remove(reply.getId());
                }
+               synchronized (newReplies) {
+                       markReplyKnown(reply);
+                       knownReplies.remove(reply.getId());
+               }
                sone.removeReply(reply);
                saveSone(sone);
        }
        }
  
        /**
 +       * 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);
 +              }
 +      }
 +
 +      /**
         * Starts the core.
         */
        public void start() {
                try {
                        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/PositiveTrust").setValue(options.getIntegerOption("PositiveTrust").getReal());
                        configuration.getIntValue("Option/NegativeTrust").setValue(options.getIntegerOption("NegativeTrust").getReal());
                        configuration.getStringValue("Option/TrustComment").setValue(options.getStringOption("TrustComment").getReal());
                        }
  
                }));
+               options.addIntegerOption("PostsPerPage", new DefaultOption<Integer>(10));
                options.addIntegerOption("PositiveTrust", new DefaultOption<Integer>(75));
-               options.addIntegerOption("NegativeTrust", new DefaultOption<Integer>(-100));
+               options.addIntegerOption("NegativeTrust", new DefaultOption<Integer>(-25));
                options.addStringOption("TrustComment", new DefaultOption<String>("Set from Sone Web Interface"));
                options.addBooleanOption("SoneRescueMode", new DefaultOption<Boolean>(false));
                options.addBooleanOption("ClearOnNextRestart", new DefaultOption<Boolean>(false));
                }
  
                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));
                options.getStringOption("TrustComment").set(configuration.getStringValue("Option/TrustComment").getValue(null));
                coreListenerManager.fireUpdateFound(version, releaseTime, latestEdition);
        }
  
 +      //
 +      // INTERFACE ImageInsertListener
 +      //
 +
 +      /**
 +       * {@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.
                }
  
                /**
+                * Returns the number of posts to show per page.
+                *
+                * @return The number of posts to show per page
+                */
+               public int getPostsPerPage() {
+                       return options.getIntegerOption("PostsPerPage").get();
+               }
+               /**
+                * Sets the number of posts to show per page.
+                *
+                * @param postsPerPage
+                *            The number of posts to show per page
+                * @return This preferences object
+                */
+               public Preferences setPostsPerPage(Integer postsPerPage) {
+                       options.getIntegerOption("PostsPerPage").set(postsPerPage);
+                       return this;
+               }
+               /**
                 * Returns the positive trust.
                 *
                 * @return The positive trust
@@@ -27,10 -27,11 +27,12 @@@ import java.util.Set
  import java.util.logging.Level;
  import java.util.logging.Logger;
  
+ import net.pterodactylus.sone.core.Options;
  import net.pterodactylus.sone.freenet.wot.Identity;
  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;
  
  /**
@@@ -57,6 -58,15 +59,15 @@@ public class Sone implements Fingerprin
  
        };
  
+       /** Filter to remove Sones that have not been downloaded. */
+       public static final Filter<Sone> EMPTY_SONE_FILTER = new Filter<Sone>() {
+               @Override
+               public boolean filterObject(Sone sone) {
+                       return sone.getTime() != 0;
+               }
+       };
        /** 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();
        /**
         * Creates a new Sone.
         *
         */
        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;
         * @return This Sone (for method chaining)
         */
        public synchronized Sone setPosts(Collection<Post> posts) {
-               this.posts.clear();
-               this.posts.addAll(posts);
+               synchronized (this) {
+                       this.posts.clear();
+                       this.posts.addAll(posts);
+               }
                return this;
        }
  
        }
  
        /**
 +       * 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
+        */
+       public Options getOptions() {
+               return options;
+       }
        //
        // FINGERPRINTABLE METHODS
        //
                }
                fingerprint.append(')');
  
 +//            fingerprint.append("Albums(");
 +//            for (Album album : albums) {
 +//                    fingerprint.append(album.getFingerprint());
 +//            }
 +//            fingerprint.append(')');
 +
                return fingerprint.toString();
        }
  
@@@ -27,6 -27,10 +27,10 @@@ import java.util.logging.Logger
  import java.util.regex.Matcher;
  import java.util.regex.Pattern;
  
+ import net.pterodactylus.sone.core.Core;
+ import net.pterodactylus.sone.data.Post;
+ import net.pterodactylus.sone.data.Sone;
+ import net.pterodactylus.sone.template.SoneAccessor;
  import net.pterodactylus.util.logging.Logging;
  import net.pterodactylus.util.template.TemplateContextFactory;
  import net.pterodactylus.util.template.TemplateParser;
@@@ -68,20 -72,32 +72,32 @@@ public class FreenetLinkParser implemen
                HTTP,
  
                /** Link is HTTPS. */
-               HTTPS;
+               HTTPS,
+               /** Link is a Sone. */
+               SONE,
+               /** Link is a post. */
+               POST,
  
        }
  
+       /** The core. */
+       private final Core core;
        /** The template factory. */
        private final TemplateContextFactory templateContextFactory;
  
        /**
         * Creates a new freenet link parser.
         *
+        * @param core
+        *            The core
         * @param templateContextFactory
         *            The template context factory
         */
-       public FreenetLinkParser(TemplateContextFactory templateContextFactory) {
+       public FreenetLinkParser(Core core, TemplateContextFactory templateContextFactory) {
+               this.core = core;
                this.templateContextFactory = templateContextFactory;
        }
  
                PartContainer parts = new PartContainer();
                BufferedReader bufferedReader = (source instanceof BufferedReader) ? (BufferedReader) source : new BufferedReader(source);
                String line;
+               boolean lastLineEmpty = true;
+               int emptyLines = 0;
                while ((line = bufferedReader.readLine()) != null) {
-                       line = line.trim() + "\n";
+                       if (line.trim().length() == 0) {
+                               if (lastLineEmpty) {
+                                       continue;
+                               }
+                               parts.add(createPlainTextPart("\n"));
+                               ++emptyLines;
+                               lastLineEmpty = emptyLines == 2;
+                               continue;
+                       }
+                       emptyLines = 0;
+                       boolean lineComplete = true;
                        while (line.length() > 0) {
                                int nextKsk = line.indexOf("KSK@");
                                int nextChk = line.indexOf("CHK@");
                                int nextUsk = line.indexOf("USK@");
                                int nextHttp = line.indexOf("http://");
                                int nextHttps = line.indexOf("https://");
-                               if ((nextKsk == -1) && (nextChk == -1) && (nextSsk == -1) && (nextUsk == -1) && (nextHttp == -1) && (nextHttps == -1)) {
-                                       parts.add(createPlainTextPart(line));
+                               int nextSone = line.indexOf("sone://");
+                               int nextPost = line.indexOf("post://");
+                               if ((nextKsk == -1) && (nextChk == -1) && (nextSsk == -1) && (nextUsk == -1) && (nextHttp == -1) && (nextHttps == -1) && (nextSone == -1) && (nextPost == -1)) {
+                                       if (lineComplete && !lastLineEmpty) {
+                                               parts.add(createPlainTextPart("\n" + line));
+                                       } else {
+                                               parts.add(createPlainTextPart(line));
+                                       }
                                        break;
                                }
                                int next = Integer.MAX_VALUE;
                                        next = nextHttps;
                                        linkType = LinkType.HTTPS;
                                }
+                               if ((nextSone > -1) && (nextSone < next)) {
+                                       next = nextSone;
+                                       linkType = LinkType.SONE;
+                               }
+                               if ((nextPost > -1) && (nextPost < next)) {
+                                       next = nextPost;
+                                       linkType = LinkType.POST;
+                               }
                                if ((next >= 8) && (line.substring(next - 8, next).equals("freenet:"))) {
                                        next -= 8;
                                        line = line.substring(0, next) + line.substring(next + 8);
                                Matcher matcher = whitespacePattern.matcher(line);
                                int nextSpace = matcher.find(next) ? matcher.start() : line.length();
                                if (nextSpace > (next + 4)) {
-                                       parts.add(createPlainTextPart(line.substring(0, next)));
+                                       if (!lastLineEmpty && lineComplete) {
+                                               parts.add(createPlainTextPart("\n" + line.substring(0, next)));
+                                       } else {
+                                               parts.add(createPlainTextPart(line.substring(0, next)));
+                                       }
                                        String link = line.substring(next, nextSpace);
                                        String name = link;
                                        logger.log(Level.FINER, "Found link: %s", link);
                                                        if (name == null) {
                                                                name = link.substring(0, Math.min(9, link.length()));
                                                        }
 -                                                      boolean fromPostingSone = ((linkType == LinkType.SSK) || (linkType == LinkType.USK)) && link.substring(4, Math.min(link.length(), 47)).equals(context.getPostingSone().getId());
 +                                                      boolean fromPostingSone = (context.getPostingSone() != null) && ((linkType == LinkType.SSK) || (linkType == LinkType.USK)) && link.substring(4, Math.min(link.length(), 47)).equals(context.getPostingSone().getId());
                                                        parts.add(fromPostingSone ? createTrustedFreenetLinkPart(link, name) : createFreenetLinkPart(link, name));
                                                } catch (MalformedURLException mue1) {
                                                        /* not a valid link, insert as plain text. */
                                                }
                                                link = "?_CHECKED_HTTP_=" + link;
                                                parts.add(createInternetLinkPart(link, name));
+                                       } else if (linkType == LinkType.SONE) {
+                                               String soneId = link.substring(7);
+                                               Sone sone = core.getSone(soneId, false);
+                                               if (sone != null) {
+                                                       parts.add(createInSoneLinkPart("viewSone.html?sone=" + soneId, SoneAccessor.getNiceName(sone)));
+                                               } else {
+                                                       parts.add(createPlainTextPart(link));
+                                               }
+                                       } else if (linkType == LinkType.POST) {
+                                               String postId = link.substring(7);
+                                               Post post = core.getPost(postId, false);
+                                               if (post != null) {
+                                                       String postText = post.getText();
+                                                       postText = postText.substring(0, Math.min(postText.length(), 20)) + "…";
+                                                       Sone postSone = post.getSone();
+                                                       parts.add(createInSoneLinkPart("viewPost.html?post=" + postId, postText, (postSone == null) ? postText : SoneAccessor.getNiceName(post.getSone())));
+                                               } else {
+                                                       parts.add(createPlainTextPart(link));
+                                               }
                                        }
                                        line = line.substring(nextSpace);
                                } else {
-                                       parts.add(createPlainTextPart(line.substring(0, next + 4)));
+                                       if (!lastLineEmpty && lineComplete) {
+                                               parts.add(createPlainTextPart("\n" + line.substring(0, next + 4)));
+                                       } else {
+                                               parts.add(createPlainTextPart(line.substring(0, next + 4)));
+                                       }
                                        line = line.substring(next + 4);
                                }
+                               lineComplete = false;
                        }
+                       lastLineEmpty = false;
+               }
+               for (int partIndex = parts.size() - 1; partIndex >= 0; --partIndex) {
+                       if (!parts.getPart(partIndex).toString().equals("\n")) {
+                               break;
+                       }
+                       parts.removePart(partIndex);
                }
                return parts;
        }
                return new TemplatePart(templateContextFactory, TemplateParser.parse(new StringReader("<a class=\"freenet-trusted\" href=\"/<% link|html>\" title=\"<% link|html>\"><% name|html></a>"))).set("link", link).set("name", name);
        }
  
+       /**
+        * Creates a new part based on a template that links to a page in Sone.
+        *
+        * @param link
+        *            The target of the link
+        * @param name
+        *            The name of the link
+        * @return The part that displays the link
+        */
+       private Part createInSoneLinkPart(String link, String name) {
+               return createInSoneLinkPart(link, name, name);
+       }
+       /**
+        * Creates a new part based on a template that links to a page in Sone.
+        *
+        * @param link
+        *            The target of the link
+        * @param name
+        *            The name of the link
+        * @param title
+        *            The title attribute of the link
+        * @return The part that displays the link
+        */
+       private Part createInSoneLinkPart(String link, String name, String title) {
+               return new TemplatePart(templateContextFactory, TemplateParser.parse(new StringReader("<a class=\"in-sone\" href=\"<%link|html>\" title=\"<%title|html>\"><%name|html></a>"))).set("link", link).set("name", name).set("title", title);
+       }
  }
@@@ -36,8 -36,6 +36,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,17 -44,16 +46,18 @@@ 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.GetPagePlugin;
+ 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;
+ import net.pterodactylus.sone.template.ReplyGroupFilter;
  import net.pterodactylus.sone.template.RequestChangeFilter;
  import net.pterodactylus.sone.template.SoneAccessor;
  import net.pterodactylus.sone.template.SubstringFilter;
@@@ -76,6 -73,7 +77,7 @@@ import net.pterodactylus.sone.web.ajax.
  import net.pterodactylus.sone.web.ajax.GetPostAjaxPage;
  import net.pterodactylus.sone.web.ajax.GetReplyAjaxPage;
  import net.pterodactylus.sone.web.ajax.GetStatusAjaxPage;
+ import net.pterodactylus.sone.web.ajax.GetTimesAjaxPage;
  import net.pterodactylus.sone.web.ajax.GetTranslationPage;
  import net.pterodactylus.sone.web.ajax.LikeAjaxPage;
  import net.pterodactylus.sone.web.ajax.LockSoneAjaxPage;
@@@ -89,7 -87,9 +91,9 @@@ import net.pterodactylus.sone.web.ajax.
  import net.pterodactylus.sone.web.ajax.UntrustAjaxPage;
  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;
@@@ -105,7 -105,6 +109,6 @@@ import net.pterodactylus.util.template.
  import net.pterodactylus.util.template.FormatFilter;
  import net.pterodactylus.util.template.HtmlFilter;
  import net.pterodactylus.util.template.MatchFilter;
- import net.pterodactylus.util.template.PaginationPlugin;
  import net.pterodactylus.util.template.Provider;
  import net.pterodactylus.util.template.ReflectionAccessor;
  import net.pterodactylus.util.template.ReplaceFilter;
@@@ -123,6 -122,7 +126,7 @@@ import freenet.clients.http.SessionMana
  import freenet.clients.http.ToadletContainer;
  import freenet.clients.http.ToadletContext;
  import freenet.l10n.BaseL10n;
+ import freenet.support.api.HTTPRequest;
  
  /**
   * Bundles functionality that a web interface of a Freenet plugin needs, e.g.
@@@ -174,15 -174,6 +178,15 @@@ public class WebInterface implements Co
        /** 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.
         *
                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("html", new HtmlFilter());
                templateContextFactory.addFilter("replace", new ReplaceFilter());
                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.addProvider(Provider.TEMPLATE_CONTEXT_PROVIDER);
                templateContextFactory.addProvider(new ClassPathTemplateProvider());
                templateContextFactory.addTemplateObject("formPassword", formPassword);
  
                /* create notifications. */
                Template newSoneNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newSoneNotification.html"));
-               newSoneNotification = new ListNotification<Sone>("new-sone-notification", "sones", newSoneNotificationTemplate);
+               newSoneNotification = new ListNotification<Sone>("new-sone-notification", "sones", newSoneNotificationTemplate, false);
  
                Template newPostNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newPostNotification.html"));
-               newPostNotification = new ListNotification<Post>("new-post-notification", "posts", newPostNotificationTemplate);
+               newPostNotification = new ListNotification<Post>("new-post-notification", "posts", newPostNotificationTemplate, false);
  
                Template newReplyNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newReplyNotification.html"));
-               newReplyNotification = new ListNotification<Reply>("new-replies-notification", "replies", newReplyNotificationTemplate);
+               newReplyNotification = new ListNotification<Reply>("new-replies-notification", "replies", newReplyNotificationTemplate, false);
  
                Template rescuingSonesTemplate = TemplateParser.parse(createReader("/templates/notify/rescuingSonesNotification.html"));
                rescuingSonesNotification = new ListNotification<Sone>("sones-being-rescued-notification", "sones", rescuingSonesTemplate);
  
                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);
        }
  
        //
                Template createPostTemplate = TemplateParser.parse(createReader("/templates/createPost.html"));
                Template createReplyTemplate = TemplateParser.parse(createReader("/templates/createReply.html"));
                Template bookmarksTemplate = TemplateParser.parse(createReader("/templates/bookmarks.html"));
+               Template searchTemplate = TemplateParser.parse(createReader("/templates/search.html"));
                Template editProfileTemplate = TemplateParser.parse(createReader("/templates/editProfile.html"));
                Template editProfileFieldTemplate = TemplateParser.parse(createReader("/templates/editProfileField.html"));
                Template deleteProfileFieldTemplate = TemplateParser.parse(createReader("/templates/deleteProfileField.html"));
                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 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 replyTemplate = TemplateParser.parse(createReader("/templates/include/viewReply.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 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 BookmarkPage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new UnbookmarkPage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new BookmarksPage(bookmarksTemplate, this), "Bookmarks"));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new SearchPage(searchTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteSonePage(deleteSoneTemplate, this), "DeleteSone"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new LoginPage(loginTemplate, this), "Login"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new LogoutPage(emptyTemplate, this), "Logout"));
                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 GetImagePage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetTranslationPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetStatusAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DismissNotificationAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new CreateReplyAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetReplyAjaxPage(this, replyTemplate)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetPostAjaxPage(this, postTemplate)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new GetTimesAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new MarkAsKnownAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DeletePostAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteReplyAjaxPage(this)));
        }
  
        /**
 +       * {@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,8 -12,6 +12,8 @@@ 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
@@@ -25,12 -23,21 +25,21 @@@ Navigation.Menu.Item.About.Tooltip=Info
  
  Page.About.Title=About - Sone
  Page.About.Page.Title=About
+ Page.About.Flattr.Description=If you like Sone and you would like to reward me, you can use the Flattr button at the bottom of each page. Flattr is a non-anonymous micro payment that acts like an internet tip jar where the amount each user spends is limited (lowest being 2 € per month). More information can be found on {link}flattr.com{/link}.
+ Page.About.Homepage.Title=Homepage
+ Page.About.Homepage.Description=You can find more information and the source code of Sone on the {link}homepage{/link}.
+ Page.About.License.Title=License
  
  Page.Options.Title=Options - Sone
  Page.Options.Page.Title=Options
  Page.Options.Page.Description=These options influence the runtime behaviour of the Sone plugin.
+ 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.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.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.
@@@ -127,6 -134,7 +136,7 @@@ Page.ViewSone.PostList.Title=Posts by {
  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.ViewPost.Title=View Post - Sone
  Page.ViewPost.Page.Title=View Post by {sone}
@@@ -156,47 -164,6 +166,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
@@@ -212,6 -179,12 +222,12 @@@ Page.Bookmarks.Page.Title=Bookmark
  Page.Bookmarks.Text.NoBookmarks=You don’t have any bookmarks defined right now. You can bookmark posts by clicking the star below the post.
  Page.Bookmarks.Text.PostsNotLoaded=Some of your bookmarked posts have not been shown because they could not be loaded. This can happen if you restarted Sone recently or if the originating Sone has deleted the post. If you are reasonable sure that these posts do not exist anymore, you can {link}unbookmark them{/link}.
  
+ Page.Search.Title=Search - Sone
+ Page.Search.Page.Title=Search Results
+ Page.Search.Text.SoneHits=The following Sones match your search terms.
+ Page.Search.Text.PostHits=The following posts match your search terms.
+ Page.Search.Text.NoHits=No Sones or posts matched your search terms.
  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!
@@@ -227,10 -200,12 +243,12 @@@ Page.Invalid.Title=Invalid Action Perfo
  Page.Invalid.Page.Title=Invalid Action Performed
  Page.Invalid.Text=An invalid action was performed, or the action was valid but the parameters were not. Please go back to the {link}index page{/link} and try again. If the error persists you have probably found a bug.
  
+ View.Search.Button.Search=Search
  View.CreateSone.Text.WotIdentityRequired=To create a Sone you need an identity from the {link}Web of Trust plugin{/link}.
  View.CreateSone.Select.Default=Select an identity
  View.CreateSone.Text.NoIdentities=You do not have any Web of Trust identities. Please head over to the {link}Web of Trust plugin{/link} and create an identity.
- View.CreateSone.Text.NoNonSoneIdentities=You do not have any Web of Trust identities that are not already a Sone. Please head over to the {link}Web of Trust plugin{/link} and create an identity.
+ View.CreateSone.Text.NoNonSoneIdentities=You do not have any Web of Trust identities that are not already a Sone. Use one of the remaining Web of Trust identities to create a new Sone or head over to the {link}Web of Trust plugin{/link} to create a new identity.
  View.CreateSone.Button.Create=Create Sone
  View.CreateSone.Text.Error.NoIdentity=You have not selected an identity.
  
@@@ -257,6 -232,7 +275,7 @@@ View.Post.Reply.DeleteLink=Delet
  View.Post.LikeLink=Like
  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.UpdateStatus.Text.ChooseSenderIdentity=Choose the sender identity
  
@@@ -264,15 -240,23 +283,32 @@@ 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
+ View.Time.AMinuteAgo=about a minute ago
+ View.Time.XMinutesAgo=${min} minutes ago
+ View.Time.HalfAnHourAgo=half an hour ago
+ View.Time.AnHourAgo=about an hour ago
+ View.Time.XHoursAgo=${hour} hours ago
+ 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.AMonthAgo=about a month ago
+ View.Time.XMonthsAgo=${month} months ago
+ View.Time.AYearAgo=about a year ago
+ View.Time.XYearsAgo=${year} years ago
  WebInterface.DefaultText.StatusUpdate=What’s on your mind?
  WebInterface.DefaultText.Message=Write a Message…
  WebInterface.DefaultText.Reply=Write a Reply…
@@@ -284,15 -268,11 +320,19 @@@ 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.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.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.Confirmation.DeletePostButton=Yes, delete!
  WebInterface.Confirmation.DeleteReplyButton=Yes, delete!
  WebInterface.SelectBox.Choose=Choose…
@@@ -313,12 -293,9 +353,12 @@@ Notification.NewPost.ShortText=New post
  Notification.NewPost.Text=New posts have been discovered by the following Sones:
  Notification.NewPost.Button.MarkRead=Mark as read
  Notification.NewReply.ShortText=New replies have been discovered.
- Notification.NewReply.Text=New replies have been discovered by the following Sones:
+ Notification.NewReply.Text=New replies have been discovered for posts by the following Sones:
  Notification.SoneIsBeingRescued.Text=The following Sones are currently being rescued:
  Notification.SoneRescued.Text=The following Sones have been rescued:
  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:
@@@ -93,10 -93,6 +93,10 @@@ textarea 
        border: none;
  }
  
 +#sone .parsed {
 +      white-space: pre-wrap;
 +}
 +
  #sone #notification-area {
        margin-top: 1em;
  }
        float: right;
  }
  
+ #sone #notification-area .notification .hidden {
+       display: none;
+ }
  #sone #plugin-warning {
        border: solid 0.5em red;
        padding: 0.5em;
        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;
+ }
+ #sone #search input[type=text] {
+       width: 35em;
+ }
+ #sone #sone-results + #sone #post-results {
+       clear: both;
+       padding-top: 1em;
+ }
  #sone #tail {
        margin-top: 1em;
        border-top: solid 1px #ccc;
        font-weight: bold;
  }
  
 -#sone input.default {
 +#sone input.default, #sone textarea.default {
        color: #888;
  }