Merge branch 'next' into image-management
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 22 Mar 2011 18:46:01 +0000 (19:46 +0100)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 22 Mar 2011 18:46:01 +0000 (19:46 +0100)
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

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/template/AlbumAccessor.java
src/main/java/net/pterodactylus/sone/web/CreateAlbumPage.java
src/main/java/net/pterodactylus/sone/web/ImageBrowserPage.java
src/main/java/net/pterodactylus/sone/web/WebInterface.java
src/main/resources/i18n/sone.en.properties
src/main/resources/static/javascript/sone.js
src/main/resources/templates/insert/sone.xml

@@@ -31,22 -31,24 +31,26 @@@ 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.freenet.wot.Identity;
  import net.pterodactylus.sone.freenet.wot.IdentityListener;
  import net.pterodactylus.sone.freenet.wot.IdentityManager;
  import net.pterodactylus.sone.freenet.wot.OwnIdentity;
+ import net.pterodactylus.sone.freenet.wot.Trust;
+ import net.pterodactylus.sone.freenet.wot.WebOfTrustException;
  import net.pterodactylus.sone.main.SonePlugin;
  import net.pterodactylus.util.config.Configuration;
  import net.pterodactylus.util.config.ConfigurationException;
  import net.pterodactylus.util.logging.Logging;
  import net.pterodactylus.util.number.Numbers;
+ import net.pterodactylus.util.validation.Validation;
  import net.pterodactylus.util.version.Version;
  import freenet.keys.FreenetURI;
  
@@@ -83,6 -85,9 +87,9 @@@ public class Core implements IdentityLi
        /** The options. */
        private final Options options = new Options();
  
+       /** The preferences. */
+       private final Preferences preferences = new Preferences(options);
        /** The core listener manager. */
        private final CoreListenerManager coreListenerManager = new CoreListenerManager(this);
  
        /** All known replies. */
        private Set<String> knownReplies = new HashSet<String>();
  
+       /** All bookmarked posts. */
+       /* synchronize access on itself. */
+       private Set<String> bookmarkedPosts = new HashSet<String>();
+       /** 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>();
 +
        /**
         * Creates a new core.
         *
         *
         * @return The options of the core
         */
-       public Options getOptions() {
-               return options;
-       }
-       /**
-        * Returns whether the “Sone rescue mode” is currently activated.
-        *
-        * @return {@code true} if the “Sone rescue mode” is currently activated,
-        *         {@code false} if it is not
-        */
-       public boolean isSoneRescueMode() {
-               return options.getBooleanOption("SoneRescueMode").get();
+       public Preferences getPreferences() {
+               return preferences;
        }
  
        /**
                        if ((sone == null) && create) {
                                sone = new Sone(id);
                                localSones.put(id, sone);
+                               setSoneStatus(sone, SoneStatus.unknown);
                        }
                        return sone;
                }
                        if ((sone == null) && create) {
                                sone = new Sone(id);
                                remoteSones.put(id, sone);
+                               setSoneStatus(sone, SoneStatus.unknown);
                        }
                        return sone;
                }
        }
  
        /**
-        * Returns whether the given Sone is a new Sone. After this check, the Sone
-        * is marked as known, i.e. a second call with the same parameters will
-        * always yield {@code false}.
+        * Returns whether the Sone with the given ID is a new Sone.
         *
-        * @param sone
-        *            The sone to check for
+        * @param soneId
+        *            The ID of the sone to check for
         * @return {@code true} if the given Sone is new, false otherwise
         */
-       public boolean isNewSone(Sone sone) {
+       public boolean isNewSone(String soneId) {
                synchronized (newSones) {
-                       boolean isNew = !knownSones.contains(sone.getId()) && newSones.remove(sone.getId());
-                       knownSones.add(sone.getId());
-                       if (isNew) {
-                               coreListenerManager.fireMarkSoneKnown(sone);
-                       }
-                       return isNew;
+                       return !knownSones.contains(soneId) && newSones.contains(soneId);
                }
        }
  
        }
  
        /**
+        * Returns whether the target Sone is trusted by the origin Sone.
+        *
+        * @param origin
+        *            The origin Sone
+        * @param target
+        *            The target Sone
+        * @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);
+       }
+       /**
         * Returns the post with the given ID.
         *
         * @param postId
        }
  
        /**
-        * Returns whether the given post ID is new. After this method returns it is
-        * marked a known post ID.
+        * Returns whether the given post ID is new.
         *
         * @param postId
         *            The post ID
         *         otherwise
         */
        public boolean isNewPost(String postId) {
-               return isNewPost(postId, true);
-       }
-       /**
-        * Returns whether the given post ID is new. If {@code markAsKnown} is
-        * {@code true} then after this method returns the post ID is marked a known
-        * post ID.
-        *
-        * @param postId
-        *            The post ID
-        * @param markAsKnown
-        *            {@code true} to mark the post ID as known, {@code false} to
-        *            not to mark it as known
-        * @return {@code true} if the post is considered to be new, {@code false}
-        *         otherwise
-        */
-       public boolean isNewPost(String postId, boolean markAsKnown) {
                synchronized (newPosts) {
-                       boolean isNew = !knownPosts.contains(postId) && newPosts.contains(postId);
-                       if (markAsKnown) {
-                               Post post = getPost(postId, false);
-                               if (post != null) {
-                                       markPostKnown(post);
-                               }
-                       }
-                       return isNew;
+                       return !knownPosts.contains(postId) && newPosts.contains(postId);
                }
        }
  
         *         otherwise
         */
        public boolean isNewReply(String replyId) {
-               return isNewReply(replyId, true);
-       }
-       /**
-        * Returns whether the reply with the given ID is new.
-        *
-        * @param replyId
-        *            The ID of the reply to check
-        * @param markAsKnown
-        *            {@code true} to mark the reply as known, {@code false} to not
-        *            to mark it as known
-        * @return {@code true} if the reply is considered to be new, {@code false}
-        *         otherwise
-        */
-       public boolean isNewReply(String replyId, boolean markAsKnown) {
                synchronized (newReplies) {
-                       boolean isNew = !knownReplies.contains(replyId) && newReplies.contains(replyId);
-                       if (markAsKnown) {
-                               Reply reply = getReply(replyId, false);
-                               if (reply != null) {
-                                       markReplyKnown(reply);
-                               }
-                       }
-                       return isNew;
+                       return !knownReplies.contains(replyId) && newReplies.contains(replyId);
                }
        }
  
        }
  
        /**
+        * Returns whether the given post is bookmarked.
+        *
+        * @param post
+        *            The post to check
+        * @return {@code true} if the given post is bookmarked, {@code false}
+        *         otherwise
+        */
+       public boolean isBookmarked(Post post) {
+               return isPostBookmarked(post.getId());
+       }
+       /**
+        * Returns whether the post with the given ID is bookmarked.
+        *
+        * @param id
+        *            The ID of the post to check
+        * @return {@code true} if the post with the given ID is bookmarked,
+        *         {@code false} otherwise
+        */
+       public boolean isPostBookmarked(String id) {
+               synchronized (bookmarkedPosts) {
+                       return bookmarkedPosts.contains(id);
+               }
+       }
+       /**
+        * Returns all currently known bookmarked posts.
+        *
+        * @return All bookmarked posts
+        */
+       public Set<Post> getBookmarkedPosts() {
+               Set<Post> posts = new HashSet<Post>();
+               synchronized (bookmarkedPosts) {
+                       for (String bookmarkedPostId : bookmarkedPosts) {
+                               Post post = getPost(bookmarkedPostId, false);
+                               if (post != null) {
+                                       posts.add(post);
+                               }
+                       }
+               }
+               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;
 +              }
 +      }
 +
        //
        // ACTIONS
        //
                        soneInserters.put(sone, soneInserter);
                        setSoneStatus(sone, SoneStatus.idle);
                        loadSone(sone);
-                       if (!isSoneRescueMode()) {
+                       if (!preferences.isSoneRescueMode()) {
                                soneInserter.start();
                        }
                        new Thread(new Runnable() {
                                @Override
                                @SuppressWarnings("synthetic-access")
                                public void run() {
-                                       if (!isSoneRescueMode()) {
+                                       if (!preferences.isSoneRescueMode()) {
                                                soneDownloader.fetchSone(sone);
                                                return;
                                        }
                                        coreListenerManager.fireRescuingSone(sone);
                                        lockSone(sone);
                                        long edition = sone.getLatestEdition();
-                                       while (!stopped && (edition >= 0) && isSoneRescueMode()) {
+                                       while (!stopped && (edition >= 0) && preferences.isSoneRescueMode()) {
                                                logger.log(Level.FINE, "Downloading edition " + edition + "…");
                                                soneDownloader.fetchSone(sone, sone.getRequestUri().setKeyType("SSK").setDocName("Sone-" + edition));
                                                --edition;
         * @return The created Sone
         */
        public Sone createSone(OwnIdentity ownIdentity) {
-               identityManager.addContext(ownIdentity, "Sone");
+               try {
+                       ownIdentity.addContext("Sone");
+               } catch (WebOfTrustException wote1) {
+                       logger.log(Level.SEVERE, "Could not add “Sone” context to own identity: " + ownIdentity, wote1);
+                       return null;
+               }
                Sone sone = addLocalSone(ownIdentity);
                return sone;
        }
        }
  
        /**
+        * Retrieves the trust relationship from the origin to the target. If the
+        * trust relationship can not be retrieved, {@code null} is returned.
+        *
+        * @see Identity#getTrust(OwnIdentity)
+        * @param origin
+        *            The origin of the trust tree
+        * @param target
+        *            The target of the trust
+        * @return The trust relationship
+        */
+       public Trust getTrust(Sone origin, Sone target) {
+               if (!isLocalSone(origin)) {
+                       logger.log(Level.WARNING, "Tried to get trust from remote Sone: %s", origin);
+                       return null;
+               }
+               return target.getIdentity().getTrust((OwnIdentity) origin.getIdentity());
+       }
+       /**
+        * Sets the trust value of the given origin Sone for the target Sone.
+        *
+        * @param origin
+        *            The origin Sone
+        * @param target
+        *            The target Sone
+        * @param trustValue
+        *            The trust value (from {@code -100} to {@code 100})
+        */
+       public void setTrust(Sone origin, Sone target, int trustValue) {
+               Validation.begin().isNotNull("Trust Origin", origin).check().isInstanceOf("Trust Origin", origin.getIdentity(), OwnIdentity.class).isNotNull("Trust Target", target).isLessOrEqual("Trust Value", trustValue, 100).isGreaterOrEqual("Trust Value", trustValue, -100).check();
+               try {
+                       ((OwnIdentity) origin.getIdentity()).setTrust(target.getIdentity(), trustValue, preferences.getTrustComment());
+               } catch (WebOfTrustException wote1) {
+                       logger.log(Level.WARNING, "Could not set trust for Sone: " + target, wote1);
+               }
+       }
+       /**
+        * Removes any trust assignment for the given target Sone.
+        *
+        * @param origin
+        *            The trust origin
+        * @param target
+        *            The trust target
+        */
+       public void removeTrust(Sone origin, Sone target) {
+               Validation.begin().isNotNull("Trust Origin", origin).isNotNull("Trust Target", target).check().isInstanceOf("Trust Origin Identity", origin.getIdentity(), OwnIdentity.class).check();
+               try {
+                       ((OwnIdentity) origin.getIdentity()).removeTrust(target.getIdentity());
+               } catch (WebOfTrustException wote1) {
+                       logger.log(Level.WARNING, "Could not remove trust for Sone: " + target, wote1);
+               }
+       }
+       /**
+        * Assigns the configured positive trust value for the given target.
+        *
+        * @param origin
+        *            The trust origin
+        * @param target
+        *            The trust target
+        */
+       public void trustSone(Sone origin, Sone target) {
+               setTrust(origin, target, preferences.getPositiveTrust());
+       }
+       /**
+        * Assigns the configured negative trust value for the given target.
+        *
+        * @param origin
+        *            The trust origin
+        * @param target
+        *            The trust target
+        */
+       public void distrustSone(Sone origin, Sone target) {
+               setTrust(origin, target, preferences.getNegativeTrust());
+       }
+       /**
+        * Removes the trust assignment for the given target.
+        *
+        * @param origin
+        *            The trust origin
+        * @param target
+        *            The trust target
+        */
+       public void untrustSone(Sone origin, Sone target) {
+               removeTrust(origin, target);
+       }
+       /**
         * Updates the stores Sone with the given Sone.
         *
         * @param sone
         */
        public void updateSone(Sone sone) {
                if (hasSone(sone.getId())) {
-                       boolean soneRescueMode = isLocalSone(sone) && isSoneRescueMode();
+                       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 });
                                                }
                                        }
                                }
+                               List<Post> storedPosts = storedSone.getPosts();
                                synchronized (newPosts) {
                                        for (Post post : sone.getPosts()) {
-                                               post.setSone(getSone(post.getSone().getId()));
-                                               if (!storedSone.getPosts().contains(post) && !knownPosts.contains(post.getId())) {
+                                               post.setSone(storedSone);
+                                               if (!storedPosts.contains(post) && !knownPosts.contains(post.getId())) {
                                                        newPosts.add(post.getId());
                                                        coreListenerManager.fireNewPostFound(post);
                                                }
                                                }
                                        }
                                }
+                               Set<Reply> storedReplies = storedSone.getReplies();
                                synchronized (newReplies) {
                                        for (Reply reply : sone.getReplies()) {
-                                               reply.setSone(getSone(reply.getSone().getId()));
-                                               if (!storedSone.getReplies().contains(reply) && !knownReplies.contains(reply.getId())) {
+                                               reply.setSone(storedSone);
+                                               if (!storedReplies.contains(reply) && !knownReplies.contains(reply.getId())) {
                                                        newReplies.add(reply.getId());
                                                        coreListenerManager.fireNewReplyFound(reply);
                                                }
                        localSones.remove(sone.getId());
                        soneInserters.remove(sone).stop();
                }
-               identityManager.removeContext((OwnIdentity) sone.getIdentity(), "Sone");
-               identityManager.removeProperty((OwnIdentity) sone.getIdentity(), "Sone.LatestEdition");
+               try {
+                       ((OwnIdentity) sone.getIdentity()).removeContext("Sone");
+                       ((OwnIdentity) sone.getIdentity()).removeProperty("Sone.LatestEdition");
+               } catch (WebOfTrustException wote1) {
+                       logger.log(Level.WARNING, "Could not remove context and properties from Sone: " + sone, wote1);
+               }
                try {
                        configuration.getLongValue("Sone/" + sone.getId() + "/Time").setValue(null);
                } catch (ConfigurationException ce1) {
        }
  
        /**
+        * Marks the given Sone as known. If the Sone was {@link #isNewPost(String)
+        * new} before, a {@link CoreListener#markSoneKnown(Sone)} event is fired.
+        *
+        * @param sone
+        *            The Sone to mark as known
+        */
+       public void markSoneKnown(Sone sone) {
+               synchronized (newSones) {
+                       if (newSones.remove(sone.getId())) {
+                               knownSones.add(sone.getId());
+                               coreListenerManager.fireMarkSoneKnown(sone);
+                               saveConfiguration();
+                       }
+               }
+       }
+       /**
         * Loads and updates the given Sone from the configuration. If any error is
         * encountered, loading is aborted and the given Sone is not changed.
         *
                profile.setBirthMonth(configuration.getIntValue(sonePrefix + "/Profile/BirthMonth").getValue(null));
                profile.setBirthYear(configuration.getIntValue(sonePrefix + "/Profile/BirthYear").getValue(null));
  
+               /* load profile fields. */
+               while (true) {
+                       String fieldPrefix = sonePrefix + "/Profile/Fields/" + profile.getFields().size();
+                       String fieldName = configuration.getStringValue(fieldPrefix + "/Name").getValue(null);
+                       if (fieldName == null) {
+                               break;
+                       }
+                       String fieldValue = configuration.getStringValue(fieldPrefix + "/Value").getValue("");
+                       profile.addField(fieldName).setValue(fieldValue);
+               }
                /* load posts. */
                Set<Post> posts = new HashSet<Post>();
                while (true) {
                }
  
                logger.log(Level.INFO, "Saving Sone: %s", sone);
-               identityManager.setProperty((OwnIdentity) sone.getIdentity(), "Sone.LatestEdition", String.valueOf(sone.getLatestEdition()));
                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.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()) {
                        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);
                }
        }
  
        }
  
        /**
+        * Bookmarks the given post.
+        *
+        * @param post
+        *            The post to bookmark
+        */
+       public void bookmark(Post post) {
+               bookmarkPost(post.getId());
+       }
+       /**
+        * Bookmarks the post with the given ID.
+        *
+        * @param id
+        *            The ID of the post to bookmark
+        */
+       public void bookmarkPost(String id) {
+               synchronized (bookmarkedPosts) {
+                       bookmarkedPosts.add(id);
+               }
+       }
+       /**
+        * Removes the given post from the bookmarks.
+        *
+        * @param post
+        *            The post to unbookmark
+        */
+       public void unbookmark(Post post) {
+               unbookmarkPost(post.getId());
+       }
+       /**
+        * Removes the post with the given ID from the bookmarks.
+        *
+        * @param id
+        *            The ID of the post to unbookmark
+        */
+       public void unbookmarkPost(String id) {
+               synchronized (bookmarkedPosts) {
+                       bookmarkedPosts.remove(id);
+               }
+       }
+       /**
         * Creates a new reply.
         *
         * @param 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);
 +              }
 +              sone.addAlbum(album);
 +              return album;
 +      }
 +
 +      /**
         * 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/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/SoneRescueMode").setValue(options.getBooleanOption("SoneRescueMode").getReal());
                        configuration.getBooleanValue("Option/ClearOnNextRestart").setValue(options.getBooleanOption("ClearOnNextRestart").getReal());
                        configuration.getBooleanValue("Option/ReallyClearOnNextRestart").setValue(options.getBooleanOption("ReallyClearOnNextRestart").getReal());
                                configuration.getStringValue("KnownReplies/" + replyCounter + "/ID").setValue(null);
                        }
  
+                       /* save bookmarked posts. */
+                       int bookmarkedPostCounter = 0;
+                       synchronized (bookmarkedPosts) {
+                               for (String bookmarkedPostId : bookmarkedPosts) {
+                                       configuration.getStringValue("Bookmarks/Post/" + bookmarkedPostCounter++ + "/ID").setValue(bookmarkedPostId);
+                               }
+                       }
+                       configuration.getStringValue("Bookmarks/Post/" + bookmarkedPostCounter++ + "/ID").setValue(null);
                        /* now save it. */
                        configuration.save();
  
                        }
  
                }));
+               options.addIntegerOption("PositiveTrust", new DefaultOption<Integer>(75));
+               options.addIntegerOption("NegativeTrust", new DefaultOption<Integer>(-100));
+               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.addBooleanOption("ReallyClearOnNextRestart", new DefaultOption<Boolean>(false));
                }
  
                options.getIntegerOption("InsertionDelay").set(configuration.getIntValue("Option/InsertionDelay").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));
                options.getBooleanOption("SoneRescueMode").set(configuration.getBooleanValue("Option/SoneRescueMode").getValue(null));
  
                /* load known Sones. */
                        }
                }
  
+               /* load bookmarked posts. */
+               int bookmarkedPostCounter = 0;
+               while (true) {
+                       String bookmarkedPostId = configuration.getStringValue("Bookmarks/Post/" + bookmarkedPostCounter++ + "/ID").getValue(null);
+                       if (bookmarkedPostId == null) {
+                               break;
+                       }
+                       synchronized (bookmarkedPosts) {
+                               bookmarkedPosts.add(bookmarkedPostId);
+                       }
+               }
        }
  
        /**
        public void ownIdentityAdded(OwnIdentity ownIdentity) {
                logger.log(Level.FINEST, "Adding OwnIdentity: " + ownIdentity);
                if (ownIdentity.hasContext("Sone")) {
+                       trustedIdentities.put(ownIdentity, Collections.synchronizedSet(new HashSet<Identity>()));
                        addLocalSone(ownIdentity);
                }
        }
        @Override
        public void ownIdentityRemoved(OwnIdentity ownIdentity) {
                logger.log(Level.FINEST, "Removing OwnIdentity: " + ownIdentity);
+               trustedIdentities.remove(ownIdentity);
        }
  
        /**
         * {@inheritDoc}
         */
        @Override
-       public void identityAdded(Identity identity) {
+       public void identityAdded(OwnIdentity ownIdentity, Identity identity) {
                logger.log(Level.FINEST, "Adding Identity: " + identity);
+               trustedIdentities.get(ownIdentity).add(identity);
                addRemoteSone(identity);
        }
  
         * {@inheritDoc}
         */
        @Override
-       public void identityUpdated(final Identity identity) {
+       public void identityUpdated(OwnIdentity ownIdentity, final Identity identity) {
                new Thread(new Runnable() {
  
                        @Override
                        @SuppressWarnings("synthetic-access")
                        public void run() {
                                Sone sone = getRemoteSone(identity.getId());
+                               sone.setIdentity(identity);
+                               soneDownloader.addSone(sone);
                                soneDownloader.fetchSone(sone);
                        }
                }).start();
         * {@inheritDoc}
         */
        @Override
-       public void identityRemoved(Identity identity) {
-               /* TODO */
+       public void identityRemoved(OwnIdentity ownIdentity, Identity identity) {
+               trustedIdentities.get(ownIdentity).remove(identity);
        }
  
        //
         * {@inheritDoc}
         */
        @Override
-       public void updateFound(Version version, long releaseTime) {
-               coreListenerManager.fireUpdateFound(version, releaseTime);
+       public void updateFound(Version version, long releaseTime, long latestEdition) {
+               coreListenerManager.fireUpdateFound(version, releaseTime, latestEdition);
+       }
+       /**
+        * Convenience interface for external classes that want to access the core’s
+        * configuration.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       public static class Preferences {
+               /** The wrapped options. */
+               private final Options options;
+               /**
+                * Creates a new preferences object wrapped around the given options.
+                *
+                * @param options
+                *            The options to wrap
+                */
+               public Preferences(Options options) {
+                       this.options = options;
+               }
+               /**
+                * Returns the insertion delay.
+                *
+                * @return The insertion delay
+                */
+               public int getInsertionDelay() {
+                       return options.getIntegerOption("InsertionDelay").get();
+               }
+               /**
+                * Sets the insertion delay
+                *
+                * @param insertionDelay
+                *            The new insertion delay, or {@code null} to restore it to
+                *            the default value
+                * @return This preferences
+                */
+               public Preferences setInsertionDelay(Integer insertionDelay) {
+                       options.getIntegerOption("InsertionDelay").set(insertionDelay);
+                       return this;
+               }
+               /**
+                * Returns the positive trust.
+                *
+                * @return The positive trust
+                */
+               public int getPositiveTrust() {
+                       return options.getIntegerOption("PositiveTrust").get();
+               }
+               /**
+                * Sets the positive trust.
+                *
+                * @param positiveTrust
+                *            The new positive trust, or {@code null} to restore it to
+                *            the default vlaue
+                * @return This preferences
+                */
+               public Preferences setPositiveTrust(Integer positiveTrust) {
+                       options.getIntegerOption("PositiveTrust").set(positiveTrust);
+                       return this;
+               }
+               /**
+                * Returns the negative trust.
+                *
+                * @return The negative trust
+                */
+               public int getNegativeTrust() {
+                       return options.getIntegerOption("NegativeTrust").get();
+               }
+               /**
+                * Sets the negative trust.
+                *
+                * @param negativeTrust
+                *            The negative trust, or {@code null} to restore it to the
+                *            default value
+                * @return The preferences
+                */
+               public Preferences setNegativeTrust(Integer negativeTrust) {
+                       options.getIntegerOption("NegativeTrust").set(negativeTrust);
+                       return this;
+               }
+               /**
+                * Returns the trust comment. This is the comment that is set in the web
+                * of trust when a trust value is assigned to an identity.
+                *
+                * @return The trust comment
+                */
+               public String getTrustComment() {
+                       return options.getStringOption("TrustComment").get();
+               }
+               /**
+                * Sets the trust comment.
+                *
+                * @param trustComment
+                *            The trust comment, or {@code null} to restore it to the
+                *            default value
+                * @return This preferences
+                */
+               public Preferences setTrustComment(String trustComment) {
+                       options.getStringOption("TrustComment").set(trustComment);
+                       return this;
+               }
+               /**
+                * Returns whether the rescue mode is active.
+                *
+                * @return {@code true} if the rescue mode is active, {@code false}
+                *         otherwise
+                */
+               public boolean isSoneRescueMode() {
+                       return options.getBooleanOption("SoneRescueMode").get();
+               }
+               /**
+                * Sets whether the rescue mode is active.
+                *
+                * @param soneRescueMode
+                *            {@code true} if the rescue mode is active, {@code false}
+                *            otherwise
+                * @return This preferences
+                */
+               public Preferences setSoneRescueMode(Boolean soneRescueMode) {
+                       options.getBooleanOption("SoneRescueMode").set(soneRescueMode);
+                       return this;
+               }
+               /**
+                * Returns whether Sone should clear its settings on the next restart.
+                * In order to be effective, {@link #isReallyClearOnNextRestart()} needs
+                * to return {@code true} as well!
+                *
+                * @return {@code true} if Sone should clear its settings on the next
+                *         restart, {@code false} otherwise
+                */
+               public boolean isClearOnNextRestart() {
+                       return options.getBooleanOption("ClearOnNextRestart").get();
+               }
+               /**
+                * Sets whether Sone will clear its settings on the next restart.
+                *
+                * @param clearOnNextRestart
+                *            {@code true} if Sone should clear its settings on the next
+                *            restart, {@code false} otherwise
+                * @return This preferences
+                */
+               public Preferences setClearOnNextRestart(Boolean clearOnNextRestart) {
+                       options.getBooleanOption("ClearOnNextRestart").set(clearOnNextRestart);
+                       return this;
+               }
+               /**
+                * Returns whether Sone should really clear its settings on next
+                * restart. This is a confirmation option that needs to be set in
+                * addition to {@link #isClearOnNextRestart()} in order to clear Sone’s
+                * settings on the next restart.
+                *
+                * @return {@code true} if Sone should really clear its settings on the
+                *         next restart, {@code false} otherwise
+                */
+               public boolean isReallyClearOnNextRestart() {
+                       return options.getBooleanOption("ReallyClearOnNextRestart").get();
+               }
+               /**
+                * Sets whether Sone should really clear its settings on the next
+                * restart.
+                *
+                * @param reallyClearOnNextRestart
+                *            {@code true} if Sone should really clear its settings on
+                *            the next restart, {@code false} otherwise
+                * @return This preferences
+                */
+               public Preferences setReallyClearOnNextRestart(Boolean reallyClearOnNextRestart) {
+                       options.getBooleanOption("ReallyClearOnNextRestart").set(reallyClearOnNextRestart);
+                       return this;
+               }
        }
  
  }
@@@ -30,7 -30,6 +30,7 @@@ import java.util.logging.Logger
  import net.pterodactylus.sone.freenet.wot.Identity;
  import net.pterodactylus.sone.template.SoneAccessor;
  import net.pterodactylus.util.logging.Logging;
 +import net.pterodactylus.util.validation.Validation;
  import freenet.keys.FreenetURI;
  
  /**
@@@ -41,7 -40,7 +41,7 @@@
   *
   * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
   */
- public class Sone implements Fingerprintable {
+ public class Sone implements Fingerprintable, Comparable<Sone> {
  
        /** comparator that sorts Sones by their nice name. */
        public static final Comparator<Sone> NICE_NAME_COMPARATOR = new Comparator<Sone>() {
        /** 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>());
 +
        /**
         * Creates a new Sone.
         *
         */
        public Sone setRequestUri(FreenetURI requestUri) {
                if (this.requestUri == null) {
-                       this.requestUri = requestUri.setDocName("Sone").setMetaString(new String[0]);
+                       this.requestUri = requestUri.setKeyType("USK").setDocName("Sone").setMetaString(new String[0]);
                        return this;
                }
                if (!this.requestUri.equalsKeypair(requestUri)) {
         */
        public Sone setInsertUri(FreenetURI insertUri) {
                if (this.insertUri == null) {
-                       this.insertUri = insertUri.setDocName("Sone").setMetaString(new String[0]);
+                       this.insertUri = insertUri.setKeyType("USK").setDocName("Sone").setMetaString(new String[0]);
                        return this;
                }
                if (!this.insertUri.equalsKeypair(insertUri)) {
                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);
 +      }
 +
 +      /**
 +       * 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);
 +      }
 +
        //
        // FINGERPRINTABLE METHODS
        //
        @Override
        public synchronized String getFingerprint() {
                StringBuilder fingerprint = new StringBuilder();
-               fingerprint.append("Profile(");
-               if (profile.getFirstName() != null) {
-                       fingerprint.append("FirstName(").append(profile.getFirstName()).append(')');
-               }
-               if (profile.getMiddleName() != null) {
-                       fingerprint.append("MiddleName(").append(profile.getMiddleName()).append(')');
-               }
-               if (profile.getLastName() != null) {
-                       fingerprint.append("LastName(").append(profile.getLastName()).append(')');
-               }
-               if (profile.getBirthDay() != null) {
-                       fingerprint.append("BirthDay(").append(profile.getBirthDay()).append(')');
-               }
-               if (profile.getBirthMonth() != null) {
-                       fingerprint.append("BirthMonth(").append(profile.getBirthMonth()).append(')');
-               }
-               if (profile.getBirthYear() != null) {
-                       fingerprint.append("BirthYear(").append(profile.getBirthYear()).append(')');
-               }
-               fingerprint.append(")");
+               fingerprint.append(profile.getFingerprint());
  
                fingerprint.append("Posts(");
                for (Post post : getPosts()) {
                }
                fingerprint.append(')');
  
 +              fingerprint.append("Albums(");
 +              for (Album album : albums) {
 +                      fingerprint.append(album.getFingerprint());
 +              }
 +              fingerprint.append(')');
 +
                return fingerprint.toString();
        }
  
        //
+       // INTERFACE Comparable<Sone>
+       //
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public int compareTo(Sone sone) {
+               return NICE_NAME_COMPARATOR.compare(this, sone);
+       }
+       //
        // OBJECT METHODS
        //
  
index e2c6f16,0000000..5042995
mode 100644,000000..100644
--- /dev/null
@@@ -1,78 -1,0 +1,78 @@@
- import net.pterodactylus.util.template.DataProvider;
 +/*
 + * Sone - AlbumAccessor.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.template;
 +
 +import java.util.ArrayList;
 +import java.util.HashMap;
 +import java.util.List;
 +import java.util.Map;
 +
 +import net.pterodactylus.sone.data.Album;
 +import net.pterodactylus.util.template.Accessor;
-       public Object get(DataProvider dataProvider, Object object, String member) {
 +import net.pterodactylus.util.template.ReflectionAccessor;
++import net.pterodactylus.util.template.TemplateContext;
 +
 +/**
 + * {@link Accessor} implementation for {@link Album}s. A property named
 + * “backlinks” is added, it returns links to all parents and the owner Sone of
 + * an album.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class AlbumAccessor extends ReflectionAccessor {
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
-               return super.get(dataProvider, object, member);
++      public Object get(TemplateContext templateContext, Object object, String member) {
 +              Album album = (Album) object;
 +              if ("backlinks".equals(member)) {
 +                      List<Map<String, String>> backlinks = new ArrayList<Map<String, String>>();
 +                      Album currentAlbum = album;
 +                      while (currentAlbum != null) {
 +                              backlinks.add(0, createLink("imageBrowser.html?album=" + album.getId(), album.getName()));
 +                              currentAlbum = currentAlbum.getParent();
 +                      }
 +                      backlinks.add(0, createLink("viewSone.html?sone=" + album.getSone().getId(), SoneAccessor.getNiceName(album.getSone())));
 +                      return backlinks;
 +              }
++              return super.get(templateContext, object, member);
 +      }
 +
 +      //
 +      // PRIVATE METHODS
 +      //
 +
 +      /**
 +       * Creates a map containing mappings for “target” and “link.”
 +       *
 +       * @param target
 +       *            The target to link to
 +       * @param name
 +       *            The name of the link
 +       * @return The created map containing the mappings
 +       */
 +      private Map<String, String> createLink(String target, String name) {
 +              Map<String, String> link = new HashMap<String, String>();
 +              link.put("target", target);
 +              link.put("name", name);
 +              return link;
 +      }
 +
 +}
index 7e1062d,0000000..7b51fb5
mode 100644,000000..100644
--- /dev/null
@@@ -1,70 -1,0 +1,70 @@@
- import net.pterodactylus.util.template.DataProvider;
 +/*
 + * 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;
 +import net.pterodactylus.sone.web.page.Page.Request.Method;
-       protected void processTemplate(Request request, DataProvider dataProvider) throws RedirectException {
-               super.processTemplate(request, dataProvider);
 +import net.pterodactylus.util.template.Template;
++import net.pterodactylus.util.template.TemplateContext;
 +
 +/**
 + * 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
-                               dataProvider.set("nameMissing", true);
++      protected void processTemplate(Request 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;
 +                      }
 +                      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.setName(name);
 +                      throw new RedirectException("imageBrowser.html?album=" + album.getId());
 +              }
 +      }
 +
 +}
index c204128,0000000..6da7cda
mode 100644,000000..100644
--- /dev/null
@@@ -1,68 -1,0 +1,68 @@@
- import net.pterodactylus.util.template.DataProvider;
 +/*
 + * 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;
-       protected void processTemplate(Request request, DataProvider dataProvider) throws RedirectException {
-               super.processTemplate(request, dataProvider);
 +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
-                       dataProvider.set("albumRequested", true);
-                       dataProvider.set("album", album);
++      protected void processTemplate(Request 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);
-                       dataProvider.set("imageRequested", true);
-                       dataProvider.set("image", image);
++                      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);
 +              }
 +      }
 +}
@@@ -36,30 -36,37 +36,39 @@@ 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.Post;
  import net.pterodactylus.sone.data.Reply;
  import net.pterodactylus.sone.data.Sone;
  import net.pterodactylus.sone.freenet.L10nFilter;
  import net.pterodactylus.sone.freenet.wot.Identity;
+ 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.IdentityAccessor;
+ 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.RequestChangeFilter;
  import net.pterodactylus.sone.template.SoneAccessor;
  import net.pterodactylus.sone.template.SubstringFilter;
+ import net.pterodactylus.sone.template.TrustAccessor;
+ import net.pterodactylus.sone.template.UnknownDateFilter;
+ import net.pterodactylus.sone.web.ajax.BookmarkAjaxPage;
  import net.pterodactylus.sone.web.ajax.CreatePostAjaxPage;
  import net.pterodactylus.sone.web.ajax.CreateReplyAjaxPage;
  import net.pterodactylus.sone.web.ajax.DeletePostAjaxPage;
+ import net.pterodactylus.sone.web.ajax.DeleteProfileFieldAjaxPage;
  import net.pterodactylus.sone.web.ajax.DeleteReplyAjaxPage;
  import net.pterodactylus.sone.web.ajax.DismissNotificationAjaxPage;
+ import net.pterodactylus.sone.web.ajax.DistrustAjaxPage;
+ 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.GetPostAjaxPage;
@@@ -68,27 -75,42 +77,42 @@@ import net.pterodactylus.sone.web.ajax.
  import net.pterodactylus.sone.web.ajax.GetTranslationPage;
  import net.pterodactylus.sone.web.ajax.LikeAjaxPage;
  import net.pterodactylus.sone.web.ajax.LockSoneAjaxPage;
- import net.pterodactylus.sone.web.ajax.MarkPostAsKnownPage;
- import net.pterodactylus.sone.web.ajax.MarkReplyAsKnownPage;
+ import net.pterodactylus.sone.web.ajax.MarkAsKnownAjaxPage;
+ import net.pterodactylus.sone.web.ajax.MoveProfileFieldAjaxPage;
+ import net.pterodactylus.sone.web.ajax.TrustAjaxPage;
+ import net.pterodactylus.sone.web.ajax.UnbookmarkAjaxPage;
  import net.pterodactylus.sone.web.ajax.UnfollowSoneAjaxPage;
  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.PageToadlet;
  import net.pterodactylus.sone.web.page.PageToadletFactory;
  import net.pterodactylus.sone.web.page.StaticPage;
+ 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.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.DateFilter;
- import net.pterodactylus.util.template.DefaultTemplateFactory;
+ 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;
+ import net.pterodactylus.util.template.StoreFilter;
  import net.pterodactylus.util.template.Template;
+ import net.pterodactylus.util.template.TemplateContext;
+ import net.pterodactylus.util.template.TemplateContextFactory;
  import net.pterodactylus.util.template.TemplateException;
- import net.pterodactylus.util.template.TemplateFactory;
- import net.pterodactylus.util.template.TemplateProvider;
+ import net.pterodactylus.util.template.TemplateParser;
  import net.pterodactylus.util.template.XmlFilter;
  import net.pterodactylus.util.thread.Ticker;
  import net.pterodactylus.util.version.Version;
@@@ -121,8 -143,8 +145,8 @@@ public class WebInterface implements Co
        /** The form password. */
        private final String formPassword;
  
-       /** The template factory. */
-       private DefaultTemplateFactory templateFactory;
+       /** The template context factory. */
+       private final TemplateContextFactory templateContextFactory;
  
        /** The “new Sone” notification. */
        private final ListNotification<Sone> newSoneNotification;
         * @param sonePlugin
         *            The Sone plugin
         */
+       @SuppressWarnings("synthetic-access")
        public WebInterface(SonePlugin sonePlugin) {
                this.sonePlugin = sonePlugin;
                formPassword = sonePlugin.pluginRespirator().getToadletContainer().getFormPassword();
  
-               templateFactory = new DefaultTemplateFactory();
-               templateFactory.addAccessor(Object.class, new ReflectionAccessor());
-               templateFactory.addAccessor(Collection.class, new CollectionAccessor());
-               templateFactory.addAccessor(Sone.class, new SoneAccessor(getCore()));
-               templateFactory.addAccessor(Post.class, new PostAccessor(getCore(), templateFactory));
-               templateFactory.addAccessor(Reply.class, new ReplyAccessor(getCore(), templateFactory));
-               templateFactory.addAccessor(Album.class, new AlbumAccessor());
-               templateFactory.addAccessor(Identity.class, new IdentityAccessor(getCore()));
-               templateFactory.addAccessor(NotificationManager.class, new NotificationManagerAccessor());
-               templateFactory.addFilter("date", new DateFilter());
-               templateFactory.addFilter("l10n", new L10nFilter(getL10n()));
-               templateFactory.addFilter("substring", new SubstringFilter());
-               templateFactory.addFilter("xml", new XmlFilter());
-               templateFactory.addFilter("change", new RequestChangeFilter());
-               templateFactory.addFilter("match", new MatchFilter());
-               templateFactory.addFilter("css", new CssClassNameFilter());
-               templateFactory.addPlugin("getpage", new GetPagePlugin());
-               templateFactory.addPlugin("paginate", new PaginationPlugin());
-               templateFactory.setTemplateProvider(new ClassPathTemplateProvider(templateFactory));
-               templateFactory.addTemplateObject("formPassword", formPassword);
+               templateContextFactory = new TemplateContextFactory();
+               templateContextFactory.addAccessor(Object.class, new ReflectionAccessor());
+               templateContextFactory.addAccessor(Collection.class, new CollectionAccessor());
+               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.addFilter("date", new DateFilter());
+               templateContextFactory.addFilter("html", new HtmlFilter());
+               templateContextFactory.addFilter("replace", new ReplaceFilter());
+               templateContextFactory.addFilter("store", new StoreFilter());
+               templateContextFactory.addFilter("l10n", new L10nFilter(getL10n()));
+               templateContextFactory.addFilter("substring", new SubstringFilter());
+               templateContextFactory.addFilter("xml", new XmlFilter());
+               templateContextFactory.addFilter("change", new RequestChangeFilter());
+               templateContextFactory.addFilter("match", new MatchFilter());
+               templateContextFactory.addFilter("css", new CssClassNameFilter());
+               templateContextFactory.addFilter("js", new JavascriptFilter());
+               templateContextFactory.addFilter("parse", new ParserFilter(getCore(), templateContextFactory));
+               templateContextFactory.addFilter("unknown", new UnknownDateFilter(getL10n(), "View.Sone.Text.UnknownDate"));
+               templateContextFactory.addFilter("format", new FormatFilter());
+               templateContextFactory.addFilter("sort", new CollectionSortFilter());
+               templateContextFactory.addPlugin("getpage", new GetPagePlugin());
+               templateContextFactory.addPlugin("paginate", new PaginationPlugin());
+               templateContextFactory.addProvider(Provider.TEMPLATE_CONTEXT_PROVIDER);
+               templateContextFactory.addProvider(new ClassPathTemplateProvider());
+               templateContextFactory.addTemplateObject("formPassword", formPassword);
  
                /* create notifications. */
-               Template newSoneNotificationTemplate = templateFactory.createTemplate(createReader("/templates/notify/newSoneNotification.html"));
+               Template newSoneNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newSoneNotification.html"));
                newSoneNotification = new ListNotification<Sone>("new-sone-notification", "sones", newSoneNotificationTemplate);
  
-               Template newPostNotificationTemplate = templateFactory.createTemplate(createReader("/templates/notify/newPostNotification.html"));
+               Template newPostNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newPostNotification.html"));
                newPostNotification = new ListNotification<Post>("new-post-notification", "posts", newPostNotificationTemplate);
  
-               Template newReplyNotificationTemplate = templateFactory.createTemplate(createReader("/templates/notify/newReplyNotification.html"));
+               Template newReplyNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newReplyNotification.html"));
                newReplyNotification = new ListNotification<Reply>("new-replies-notification", "replies", newReplyNotificationTemplate);
  
-               Template rescuingSonesTemplate = templateFactory.createTemplate(createReader("/templates/notify/rescuingSonesNotification.html"));
+               Template rescuingSonesTemplate = TemplateParser.parse(createReader("/templates/notify/rescuingSonesNotification.html"));
                rescuingSonesNotification = new ListNotification<Sone>("sones-being-rescued-notification", "sones", rescuingSonesTemplate);
  
-               Template sonesRescuedTemplate = templateFactory.createTemplate(createReader("/templates/notify/sonesRescuedNotification.html"));
+               Template sonesRescuedTemplate = TemplateParser.parse(createReader("/templates/notify/sonesRescuedNotification.html"));
                sonesRescuedNotification = new ListNotification<Sone>("sones-rescued-notification", "sones", sonesRescuedTemplate);
  
-               Template lockedSonesTemplate = templateFactory.createTemplate(createReader("/templates/notify/lockedSonesNotification.html"));
+               Template lockedSonesTemplate = TemplateParser.parse(createReader("/templates/notify/lockedSonesNotification.html"));
                lockedSonesNotification = new ListNotification<Sone>("sones-locked-notification", "sones", lockedSonesTemplate);
  
-               Template newVersionTemplate = templateFactory.createTemplate(createReader("/templates/notify/newVersionNotification.html"));
+               Template newVersionTemplate = TemplateParser.parse(createReader("/templates/notify/newVersionNotification.html"));
                newVersionNotification = new TemplateNotification("new-version-notification", newVersionTemplate);
        }
  
        }
  
        /**
+        * Returns the template context factory of the web interface.
+        *
+        * @return The template context factory
+        */
+       public TemplateContextFactory getTemplateContextFactory() {
+               return templateContextFactory;
+       }
+       /**
         * Returns the current session, creating a new session if there is no
         * current session.
         *
         *         currently logged in
         */
        public Sone getCurrentSone(ToadletContext toadletContext, boolean create) {
+               Set<Sone> localSones = getCore().getLocalSones();
+               if (localSones.size() == 1) {
+                       return localSones.iterator().next();
+               }
                return getCurrentSone(getCurrentSession(toadletContext, create));
        }
  
         */
        public void setFirstStart(boolean firstStart) {
                if (firstStart) {
-                       Template firstStartNotificationTemplate = templateFactory.createTemplate(createReader("/templates/notify/firstStartNotification.html"));
+                       Template firstStartNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/firstStartNotification.html"));
                        Notification firstStartNotification = new TemplateNotification("first-start-notification", firstStartNotificationTemplate);
                        notificationManager.addNotification(firstStartNotification);
                }
         */
        public void setNewConfig(boolean newConfig) {
                if (newConfig && !hasFirstStartNotification()) {
-                       Template configNotReadNotificationTemplate = templateFactory.createTemplate(createReader("/templates/notify/configNotReadNotification.html"));
+                       Template configNotReadNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/configNotReadNotification.html"));
                        Notification configNotReadNotification = new TemplateNotification("config-not-read-notification", configNotReadNotificationTemplate);
                        notificationManager.addNotification(configNotReadNotification);
                }
                registerToadlets();
  
                /* notification templates. */
-               Template startupNotificationTemplate = templateFactory.createTemplate(createReader("/templates/notify/startupNotification.html"));
+               Template startupNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/startupNotification.html"));
  
                final TemplateNotification startupNotification = new TemplateNotification("startup-notification", startupNotificationTemplate);
                notificationManager.addNotification(startupNotification);
                        }
                }, "Sone Startup Notification Remover");
  
-               Template wotMissingNotificationTemplate = templateFactory.createTemplate(createReader("/templates/notify/wotMissingNotification.html"));
+               Template wotMissingNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/wotMissingNotification.html"));
                final TemplateNotification wotMissingNotification = new TemplateNotification("wot-missing-notification", wotMissingNotificationTemplate);
                Ticker.getInstance().registerEvent(System.currentTimeMillis() + (15 * 1000), new Runnable() {
  
         * Register all toadlets.
         */
        private void registerToadlets() {
-               Template emptyTemplate = templateFactory.createTemplate(new StringReader(""));
-               Template loginTemplate = templateFactory.createTemplate(createReader("/templates/login.html"));
-               Template indexTemplate = templateFactory.createTemplate(createReader("/templates/index.html"));
-               Template knownSonesTemplate = templateFactory.createTemplate(createReader("/templates/knownSones.html"));
-               Template createSoneTemplate = templateFactory.createTemplate(createReader("/templates/createSone.html"));
-               Template createPostTemplate = templateFactory.createTemplate(createReader("/templates/createPost.html"));
-               Template createReplyTemplate = templateFactory.createTemplate(createReader("/templates/createReply.html"));
-               Template editProfileTemplate = templateFactory.createTemplate(createReader("/templates/editProfile.html"));
-               Template viewSoneTemplate = templateFactory.createTemplate(createReader("/templates/viewSone.html"));
-               Template viewPostTemplate = templateFactory.createTemplate(createReader("/templates/viewPost.html"));
-               Template deletePostTemplate = templateFactory.createTemplate(createReader("/templates/deletePost.html"));
-               Template deleteReplyTemplate = templateFactory.createTemplate(createReader("/templates/deleteReply.html"));
-               Template deleteSoneTemplate = templateFactory.createTemplate(createReader("/templates/deleteSone.html"));
-               Template imageBrowserTemplate = templateFactory.createTemplate(createReader("/templates/imageBrowser.html"));
-               Template createAlbumTemplate = templateFactory.createTemplate(createReader("/templates/createAlbum.html"));
-               Template noPermissionTemplate = templateFactory.createTemplate(createReader("/templates/noPermission.html"));
-               Template optionsTemplate = templateFactory.createTemplate(createReader("/templates/options.html"));
-               Template aboutTemplate = templateFactory.createTemplate(createReader("/templates/about.html"));
-               Template postTemplate = templateFactory.createTemplate(createReader("/templates/include/viewPost.html"));
-               Template replyTemplate = templateFactory.createTemplate(createReader("/templates/include/viewReply.html"));
+               Template emptyTemplate = TemplateParser.parse(new StringReader(""));
+               Template loginTemplate = TemplateParser.parse(createReader("/templates/login.html"));
+               Template indexTemplate = TemplateParser.parse(createReader("/templates/index.html"));
+               Template knownSonesTemplate = TemplateParser.parse(createReader("/templates/knownSones.html"));
+               Template createSoneTemplate = TemplateParser.parse(createReader("/templates/createSone.html"));
+               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 editProfileTemplate = TemplateParser.parse(createReader("/templates/editProfile.html"));
+               Template editProfileFieldTemplate = TemplateParser.parse(createReader("/templates/editProfileField.html"));
+               Template deleteProfileFieldTemplate = TemplateParser.parse(createReader("/templates/deleteProfileField.html"));
+               Template viewSoneTemplate = TemplateParser.parse(createReader("/templates/viewSone.html"));
+               Template viewPostTemplate = TemplateParser.parse(createReader("/templates/viewPost.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 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"));
  
                PageToadletFactory pageToadletFactory = new PageToadletFactory(sonePlugin.pluginRespirator().getHLSimpleClient(), "/Sone/");
                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 EditProfilePage(editProfileTemplate, this), "EditProfile"));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new EditProfileFieldPage(editProfileFieldTemplate, this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteProfileFieldPage(deleteProfileFieldTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new CreatePostPage(createPostTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new CreateReplyPage(createReplyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new ViewSonePage(viewSoneTemplate, this)));
                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 TrustPage(emptyTemplate, this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new DistrustPage(emptyTemplate, this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new UntrustPage(emptyTemplate, this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new MarkAsKnownPage(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 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 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 CreateReplyAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetReplyAjaxPage(this, replyTemplate)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetPostAjaxPage(this, postTemplate)));
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new MarkPostAsKnownPage(this)));
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new MarkReplyAsKnownPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new MarkAsKnownAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DeletePostAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteReplyAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new LockSoneAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new UnlockSoneAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new FollowSoneAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new UnfollowSoneAjaxPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new TrustAjaxPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new DistrustAjaxPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new UntrustAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new LikeAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new UnlikeAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetLikesAjaxPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new BookmarkAjaxPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new UnbookmarkAjaxPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new EditProfileFieldAjaxPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteProfileFieldAjaxPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new MoveProfileFieldAjaxPage(this)));
  
                ToadletContainer toadletContainer = sonePlugin.pluginRespirator().getToadletContainer();
                toadletContainer.getPageMaker().addNavigationCategory("/Sone/index.html", "Navigation.Menu.Name", "Navigation.Menu.Tooltip", sonePlugin);
                try {
                        return new InputStreamReader(getClass().getResourceAsStream(resourceName), "UTF-8");
                } catch (UnsupportedEncodingException uee1) {
+                       System.out.println("  fail.");
                        return null;
                }
        }
         * {@inheritDoc}
         */
        @Override
-       public void updateFound(Version version, long releaseTime) {
-               newVersionNotification.set("version", version);
-               newVersionNotification.set("releaseTime", releaseTime);
+       public void updateFound(Version version, long releaseTime, long latestEdition) {
+               newVersionNotification.getTemplateContext().set("latestVersion", version);
+               newVersionNotification.getTemplateContext().set("latestEdition", latestEdition);
+               newVersionNotification.getTemplateContext().set("releaseTime", releaseTime);
                notificationManager.addNotification(newVersionNotification);
        }
  
         *
         * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
         */
-       private class ClassPathTemplateProvider implements TemplateProvider {
+       private class ClassPathTemplateProvider implements Provider {
  
-               /** The template factory. */
-               @SuppressWarnings("hiding")
-               private final TemplateFactory templateFactory;
+               /** Cache for templates. */
+               private final Cache<String, Template> templateCache = new MemoryCache<String, Template>(new ValueRetriever<String, Template>() {
+                       @Override
+                       @SuppressWarnings("synthetic-access")
+                       public CacheItem<Template> retrieve(String key) throws CacheException {
+                               Template template = findTemplate(key);
+                               if (template != null) {
+                                       return new DefaultCacheItem<Template>(template);
+                               }
+                               return null;
+                       }
+               });
  
                /**
-                * Creates a new template provider that locates templates on the
-                * classpath.
-                *
-                * @param templateFactory
-                *            The template factory to create the templates
+                * {@inheritDoc}
                 */
-               public ClassPathTemplateProvider(TemplateFactory templateFactory) {
-                       this.templateFactory = templateFactory;
+               @Override
+               @SuppressWarnings("synthetic-access")
+               public Template getTemplate(TemplateContext templateContext, String templateName) {
+                       try {
+                               return templateCache.get(templateName);
+                       } catch (CacheException ce1) {
+                               logger.log(Level.WARNING, "Could not get template for " + templateName + "!", ce1);
+                               return null;
+                       }
                }
  
                /**
-                * {@inheritDoc}
+                * Locates a template in the class path.
+                *
+                * @param templateName
+                *            The name of the template to load
+                * @return The loaded template, or {@code null} if no template could be
+                *         found
                 */
-               @Override
                @SuppressWarnings("synthetic-access")
-               public Template getTemplate(String templateName) {
+               private Template findTemplate(String templateName) {
                        Reader templateReader = createReader("/templates/" + templateName);
                        if (templateReader == null) {
                                return null;
                        }
-                       Template template = templateFactory.createTemplate(templateReader);
+                       Template template = null;
                        try {
-                               template.parse();
+                               template = TemplateParser.parse(templateReader);
                        } catch (TemplateException te1) {
                                logger.log(Level.WARNING, "Could not parse template “" + templateName + "” for inclusion!", te1);
                        }
@@@ -8,10 -8,10 +8,12 @@@ Navigation.Menu.Item.CreateSone.Name=Cr
  Navigation.Menu.Item.CreateSone.Tooltip=Create a new Sone
  Navigation.Menu.Item.KnownSones.Name=Known Sones
  Navigation.Menu.Item.KnownSones.Tooltip=Shows all known Sones
+ Navigation.Menu.Item.Bookmarks.Name=Bookmarks
+ 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
@@@ -29,6 -29,10 +31,10 @@@ Page.Options.Page.Title=Option
  Page.Options.Page.Description=These options influence the runtime behaviour of the Sone plugin.
  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.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.Cleaning.Title=Clean Up
@@@ -53,6 -57,7 +59,7 @@@ Page.DeleteSone.Button.No=No, do not de
  
  Page.Index.Title=Your Sone - Sone
  Page.Index.Label.Text=Post text:
+ Page.Index.Label.Sender=Sender:
  Page.Index.Button.Post=Post!
  Page.Index.PostList.Title=Post Feed
  Page.Index.PostList.Text.NoPostYet=Nobody has written any posts yet. You should probably start it right now!
@@@ -72,8 -77,32 +79,32 @@@ Page.EditProfile.Birthday.Title=Birthda
  Page.EditProfile.Birthday.Label.Day=Day:
  Page.EditProfile.Birthday.Label.Month=Month:
  Page.EditProfile.Birthday.Label.Year=Year:
- Page.EditProfile.Page.Status.Changed=Your changes have been saved and will be inserted shortly.
+ Page.EditProfile.Fields.Title=Custom Fields
+ Page.EditProfile.Fields.Description=Here you can enter custom fields into your profile. These fields can contain anything you want and be as terse or as verbose as you wish. Just remember that when it comes to anonymity, sometimes less is more.
+ Page.EditProfile.Fields.Button.Edit=edit
+ Page.EditProfile.Fields.Button.MoveUp=move up
+ Page.EditProfile.Fields.Button.MoveDown=move down
+ Page.EditProfile.Fields.Button.Delete=delete
+ Page.EditProfile.Fields.Button.ReallyDelete=really delete
+ Page.EditProfile.Fields.AddField.Title=Add Field
+ Page.EditProfile.Fields.AddField.Label.Name=Name:
+ Page.EditProfile.Fields.AddField.Button.AddField=Add Field
  Page.EditProfile.Button.Save=Save Profile
+ Page.EditProfile.Error.DuplicateFieldName=The field name “{fieldName}” does already exist.
+ Page.EditProfileField.Title=Edit Profile Field - Sone
+ Page.EditProfileField.Page.Title=Edit Profile Field
+ Page.EditProfileField.Text=Enter a new name for this profile field.
+ Page.EditProfileField.Error.DuplicateFieldName=The field name you entered does already exist.
+ Page.EditProfileField.Button.Save=Change
+ Page.EditProfileField.Button.Reset=Revert to old name
+ Page.EditProfileField.Button.Cancel=Do not change name
+ Page.DeleteProfileField.Title=Delete Profile Field - Sone
+ Page.DeleteProfileField.Page.Title=Delete Profile Field
+ Page.DeleteProfileField.Text=Do you really want to delete this profile field?
+ Page.DeleteProfileField.Button.Yes=Yes, delete
+ Page.DeleteProfileField.Button.No=No, do not delete
  
  Page.CreatePost.Title=Create Post - Sone
  Page.CreatePost.Page.Title=Create Post
@@@ -94,6 -123,8 +125,8 @@@ Page.ViewSone.UnknownSone.Description=T
  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.ViewPost.Title=View Post - Sone
  Page.ViewPost.Page.Title=View Post by {sone}
@@@ -123,15 -154,21 +156,30 @@@ Page.FollowSone.Title=Follow Sone - Son
  
  Page.UnfollowSone.Title=Unfollow Sone - Sone
  
 +Page.ImageBrowser.Title=Image Browser - Sone
 +Page.ImageBrowser.Page.Title=Image Browser
 +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.CreateAlbum.Button.CreateAlbum=Create Album
 +
 +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.Trust.Title=Trust Sone - Sone
+ Page.Distrust.Title=Distrust Sone - Sone
+ Page.Untrust.Title=Untrust Sone - Sone
+ Page.MarkAsKnown.Title=Mark as Known - Sone
+ Page.Bookmark.Title=Bookmark - Sone
+ Page.Unbookmark.Title=Remove Bookmark - Sone
+ Page.Bookmarks.Title=Bookmarks - Sone
+ Page.Bookmarks.Page.Title=Bookmarks
+ 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.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!
@@@ -143,6 -180,10 +191,10 @@@ Page.WotPluginMissing.Text.LoadPlugin=P
  
  Page.Logout.Title=Logout - Sone
  
+ Page.Invalid.Title=Invalid Action Performed
+ 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.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.
@@@ -151,6 -192,7 +203,7 @@@ View.CreateSone.Button.Create=Create So
  View.CreateSone.Text.Error.NoIdentity=You have not selected an identity.
  
  View.Sone.Label.LastUpdate=Last update:
+ View.Sone.Text.UnknownDate=unknown
  View.Sone.Button.UnlockSone=unlock
  View.Sone.Button.UnlockSone.Tooltip=Allow this Sone to be inserted now
  View.Sone.Button.LockSone=lock
@@@ -164,15 -206,21 +217,24 @@@ View.Sone.Status.Downloading=This Sone 
  View.Sone.Status.Inserting=This Sone is currently being inserted.
  
  View.Post.UnknownAuthor=(unknown)
+ 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
  View.Post.SendReply=Post Reply!
  View.Post.Reply.DeleteLink=Delete
  View.Post.LikeLink=Like
  View.Post.UnlikeLink=Unlike
+ View.Post.ShowSource=Toggle Parser
+ View.UpdateStatus.Text.ChooseSenderIdentity=Choose the sender identity
+ View.Trust.Tooltip.Trust=Trust this person
+ 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:
 +
  WebInterface.DefaultText.StatusUpdate=What’s on your mind?
  WebInterface.DefaultText.Message=Write a Message…
  WebInterface.DefaultText.Reply=Write a Reply…
@@@ -182,6 -230,7 +244,7 @@@ WebInterface.DefaultText.LastName=Last 
  WebInterface.DefaultText.BirthDay=Day
  WebInterface.DefaultText.BirthMonth=Month
  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.Confirmation.DeletePostButton=Yes, delete!
  WebInterface.Confirmation.DeleteReplyButton=Yes, delete!
@@@ -201,10 -250,11 +264,11 @@@ Notification.NewSone.ShortText=New Sone
  Notification.NewSone.Text=New Sones have been discovered:
  Notification.NewPost.ShortText=New posts have been discovered.
  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.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=A new version of the Sone plugin was found: Version {version}.
+ Notification.NewVersion.Text=Version {version} of the Sone plugin was found. Download it from USK@nwa8lHa271k2QvJ8aa0Ov7IHAV-DFOCFgmDt3X6BpCI,DuQSUZiI~agF8c-6tjsFFGuZ8eICrzWCILB60nT8KKo,AQACAAE/sone/{edition}​!
@@@ -47,6 -47,7 +47,7 @@@ function registerInputTextareaSwap(inpu
                                textarea.show();
                        }
                        $(inputField.get(0).form).submit(function() {
+                               inputField.attr("disabled", "disabled");
                                if (!optional && (textarea.val() == "")) {
                                        return false;
                                }
   *            The element to add a “comment” link to
   */
  function addCommentLink(postId, element, insertAfterThisElement) {
-       if ($(element).find(".show-reply-form").length > 0) {
+       if (($(element).find(".show-reply-form").length > 0) || (getPostElement(element).find(".create-reply").length == 0)) {
                return;
        }
        commentElement = (function(postId) {
+               separator = $("<span> · </span>").addClass("separator");
                var commentElement = $("<div><span>Comment</span></div>").addClass("show-reply-form").click(function() {
-                       markPostAsKnown(getPostElement(this));
                        replyElement = $("#sone .post#" + postId + " .create-reply");
                        replyElement.removeClass("hidden");
                        replyElement.removeClass("light");
@@@ -87,6 -88,7 +88,7 @@@
                return commentElement;
        })(postId);
        $(insertAfterThisElement).after(commentElement.clone(true));
+       $(insertAfterThisElement).after(separator);
  }
  
  var translations = {};
@@@ -150,7 -152,13 +152,13 @@@ function updateSoneStatus(soneId, name
                toggleClass("modified", modified);
        $("#sone .sone." + filterSoneId(soneId) + " .lock").toggleClass("hidden", locked);
        $("#sone .sone." + filterSoneId(soneId) + " .unlock").toggleClass("hidden", !locked);
-       $("#sone .sone." + filterSoneId(soneId) + " .last-update span.time").text(lastUpdated);
+       if (lastUpdated != null) {
+               $("#sone .sone." + filterSoneId(soneId) + " .last-update span.time").text(lastUpdated);
+       } else {
+               getTranslation("View.Sone.Text.UnknownDate", function(unknown) {
+                       $("#sone .sone." + filterSoneId(soneId) + " .last-update span.time").text(unknown);
+               });
+       }
        $("#sone .sone." + filterSoneId(soneId) + " .profile-link a").text(name);
  }
  
@@@ -289,6 -297,17 +297,17 @@@ function getSoneId(element) 
        return getSoneElement(element).find(".id").text();
  }
  
+ /**
+  * Returns the element of the post with the given ID.
+  *
+  * @param postId
+  *            The ID of the post
+  * @returns The element of the post
+  */
+ function getPost(postId) {
+       return $("#sone .post#" + postId);
+ }
  function getPostElement(element) {
        return $(element).closest(".post");
  }
@@@ -301,6 -320,17 +320,17 @@@ function getPostTime(element) 
        return getPostElement(element).find(".post-time").text();
  }
  
+ /**
+  * Returns the author of the post the given element belongs to.
+  *
+  * @param element
+  *            The element whose post to get the author for
+  * @returns The ID of the authoring Sone
+  */
+ function getPostAuthor(element) {
+       return getPostElement(element).find(".post-author").text();
+ }
  function getReplyElement(element) {
        return $(element).closest(".reply");
  }
@@@ -313,6 -343,17 +343,17 @@@ function getReplyTime(element) 
        return getReplyElement(element).find(".reply-time").text();
  }
  
+ /**
+  * Returns the author of the reply the given element belongs to.
+  *
+  * @param element
+  *            The element whose reply to get the author for
+  * @returns The ID of the authoring Sone
+  */
+ function getReplyAuthor(element) {
+       return getReplyElement(element).find(".reply-author").text();
+ }
  function likePost(postId) {
        $.getJSON("like.ajax", { "type": "post", "post" : postId, "formPassword": getFormPassword() }, function(data, textStatus) {
                if ((data == null) || !data.success) {
@@@ -377,6 -418,106 +418,106 @@@ function unlikeReply(replyId) 
        });
  }
  
+ /**
+  * Trusts the Sone with the given ID.
+  *
+  * @param soneId
+  *            The ID of the Sone to trust
+  */
+ function trustSone(soneId) {
+       $.getJSON("trustSone.ajax", { "formPassword" : getFormPassword(), "sone" : soneId }, function(data, textStatus) {
+               if ((data != null) && data.success) {
+                       updateTrustControls(soneId, data.trustValue);
+               }
+       });
+ }
+ /**
+  * Distrusts the Sone with the given ID, i.e. assigns a negative trust value.
+  *
+  * @param soneId
+  *            The ID of the Sone to distrust
+  */
+ function distrustSone(soneId) {
+       $.getJSON("distrustSone.ajax", { "formPassword" : getFormPassword(), "sone" : soneId }, function(data, textStatus) {
+               if ((data != null) && data.success) {
+                       updateTrustControls(soneId, data.trustValue);
+               }
+       });
+ }
+ /**
+  * Untrusts the Sone with the given ID, i.e. removes any trust assignment.
+  *
+  * @param soneId
+  *            The ID of the Sone to untrust
+  */
+ function untrustSone(soneId) {
+       $.getJSON("untrustSone.ajax", { "formPassword" : getFormPassword(), "sone" : soneId }, function(data, textStatus) {
+               if ((data != null) && data.success) {
+                       updateTrustControls(soneId, data.trustValue);
+               }
+       });
+ }
+ /**
+  * Updates the trust controls for all posts and replies of the given Sone,
+  * according to the given trust value.
+  *
+  * @param soneId
+  *            The ID of the Sone to update all trust controls for
+  * @param trustValue
+  *            The trust value for the Sone
+  */
+ function updateTrustControls(soneId, trustValue) {
+       $("#sone .post").each(function() {
+               if (getPostAuthor(this) == soneId) {
+                       getPostElement(this).find(".post-trust").toggleClass("hidden", trustValue != null);
+                       getPostElement(this).find(".post-distrust").toggleClass("hidden", trustValue != null);
+                       getPostElement(this).find(".post-untrust").toggleClass("hidden", trustValue == null);
+               }
+       });
+       $("#sone .reply").each(function() {
+               if (getReplyAuthor(this) == soneId) {
+                       getReplyElement(this).find(".reply-trust").toggleClass("hidden", trustValue != null);
+                       getReplyElement(this).find(".reply-distrust").toggleClass("hidden", trustValue != null);
+                       getReplyElement(this).find(".reply-untrust").toggleClass("hidden", trustValue == null);
+               }
+       });
+ }
+ /**
+  * Bookmarks the post with the given ID.
+  *
+  * @param postId
+  *            The ID of the post to bookmark
+  */
+ function bookmarkPost(postId) {
+       (function(postId) {
+               $.getJSON("bookmark.ajax", {"formPassword": getFormPassword(), "type": "post", "post": postId}, function(data, textStatus) {
+                       if ((data != null) && data.success) {
+                               getPost(postId).find(".bookmark").toggleClass("hidden", true);
+                               getPost(postId).find(".unbookmark").toggleClass("hidden", false);
+                       }
+               });
+       })(postId);
+ }
+ /**
+  * Unbookmarks the post with the given ID.
+  *
+  * @param postId
+  *            The ID of the post to unbookmark
+  */
+ function unbookmarkPost(postId) {
+       $.getJSON("unbookmark.ajax", {"formPassword": getFormPassword(), "type": "post", "post": postId}, function(data, textStatus) {
+               if ((data != null) && data.success) {
+                       getPost(postId).find(".bookmark").toggleClass("hidden", false);
+                       getPost(postId).find(".unbookmark").toggleClass("hidden", true);
+               }
+       });
+ }
  function updateReplyLikes(replyId) {
        $.getJSON("getLikes.ajax", { "type": "reply", "reply": replyId }, function(data, textStatus) {
                if ((data != null) && data.success) {
  /**
   * Posts a reply and calls the given callback when the request finishes.
   *
+  * @param sender
+  *            The ID of the sender
   * @param postId
   *            The ID of the post the reply refers to
   * @param text
   *            The callback function to call when the request finishes (takes 3
   *            parameters: success, error, replyId)
   */
- function postReply(postId, text, callbackFunction) {
-       $.getJSON("createReply.ajax", { "formPassword" : getFormPassword(), "post" : postId, "text": text }, function(data, textStatus) {
+ function postReply(sender, postId, text, callbackFunction) {
+       $.getJSON("createReply.ajax", { "formPassword" : getFormPassword(), "sender": sender, "post" : postId, "text": text }, function(data, textStatus) {
                if (data == null) {
                        /* TODO - show error */
                        return;
                }
                if (data.success) {
-                       callbackFunction(true, null, data.reply);
+                       callbackFunction(true, null, data.reply, data.sone);
                } else {
                        callbackFunction(false, data.error);
                }
@@@ -436,6 -579,56 +579,56 @@@ function getReply(replyId, callbackFunc
  }
  
  /**
+  * Ajaxifies the given Sone by enhancing all eligible elements with AJAX.
+  *
+  * @param soneElement
+  *            The Sone to ajaxify
+  */
+ function ajaxifySone(soneElement) {
+       /*
+        * convert all “follow”, “unfollow”, “lock”, and “unlock” links to something
+        * nicer.
+        */
+       $(".follow", soneElement).submit(function() {
+               var followElement = this;
+               $.getJSON("followSone.ajax", { "sone": getSoneId(this), "formPassword": getFormPassword() }, function() {
+                       $(followElement).addClass("hidden");
+                       $(followElement).parent().find(".unfollow").removeClass("hidden");
+               });
+               return false;
+       });
+       $(".unfollow", soneElement).submit(function() {
+               var unfollowElement = this;
+               $.getJSON("unfollowSone.ajax", { "sone": getSoneId(this), "formPassword": getFormPassword() }, function() {
+                       $(unfollowElement).addClass("hidden");
+                       $(unfollowElement).parent().find(".follow").removeClass("hidden");
+               });
+               return false;
+       });
+       $(".lock", soneElement).submit(function() {
+               var lockElement = this;
+               $.getJSON("lockSone.ajax", { "sone" : getSoneId(this), "formPassword" : getFormPassword() }, function() {
+                       $(lockElement).addClass("hidden");
+                       $(lockElement).parent().find(".unlock").removeClass("hidden");
+               });
+               return false;
+       });
+       $(".unlock", soneElement).submit(function() {
+               var unlockElement = this;
+               $.getJSON("unlockSone.ajax", { "sone" : getSoneId(this), "formPassword" : getFormPassword() }, function() {
+                       $(unlockElement).addClass("hidden");
+                       $(unlockElement).parent().find(".lock").removeClass("hidden");
+               });
+               return false;
+       });
+       /* mark Sone as known when clicking it. */
+       $(soneElement).click(function() {
+               markSoneAsKnown(soneElement);
+       });
+ }
+ /**
   * Ajaxifies the given post by enhancing all eligible elements with AJAX.
   *
   * @param postElement
@@@ -446,21 -639,24 +639,24 @@@ function ajaxifyPost(postElement) 
                return false;
        });
        $(postElement).find(".create-reply button:submit").click(function() {
-               inputField = $(this.form).find(":input:enabled").get(0);
+               sender = $(this.form).find(":input[name=sender]").val();
+               inputField = $(this.form).find(":input[name=text]:enabled").get(0);
                postId = getPostId(this);
                text = $(inputField).val();
-               (function(postId, text, inputField) {
-                       postReply(postId, text, function(success, error, replyId) {
+               (function(sender, postId, text, inputField) {
+                       postReply(sender, postId, text, function(success, error, replyId, soneId) {
                                if (success) {
                                        $(inputField).val("");
-                                       loadNewReply(replyId);
-                                       markPostAsKnown(getPostElement(inputField));
+                                       loadNewReply(replyId, soneId, postId);
                                        $("#sone .post#" + postId + " .create-reply").addClass("hidden");
+                                       $("#sone .post#" + postId + " .create-reply .sender").hide();
+                                       $("#sone .post#" + postId + " .create-reply .select-sender").show();
+                                       $("#sone .post#" + postId + " .create-reply :input[name=sender]").val(getCurrentSoneId());
                                } else {
                                        alert(error);
                                }
                        });
-               })(postId, text, inputField);
+               })(sender, postId, text, inputField);
                return false;
        });
  
        /* convert all “like” buttons to javascript functions. */
        $(postElement).find(".like-post").submit(function() {
                likePost(getPostId(this));
-               markPostAsKnown(getPostElement(this));
                return false;
        });
        $(postElement).find(".unlike-post").submit(function() {
                unlikePost(getPostId(this));
-               markPostAsKnown(getPostElement(this));
                return false;
        });
  
+       /* convert trust control buttons to javascript functions. */
+       $(postElement).find(".post-trust").submit(function() {
+               trustSone(getPostAuthor(this));
+               return false;
+       });
+       $(postElement).find(".post-distrust").submit(function() {
+               distrustSone(getPostAuthor(this));
+               return false;
+       });
+       $(postElement).find(".post-untrust").submit(function() {
+               untrustSone(getPostAuthor(this));
+               return false;
+       });
+       /* convert bookmark/unbookmark buttons to javascript functions. */
+       $(postElement).find(".bookmark").submit(function() {
+               bookmarkPost(getPostId(this));
+               return false;
+       });
+       $(postElement).find(".unbookmark").submit(function() {
+               unbookmarkPost(getPostId(this));
+               return false;
+       });
+       /* convert “show source” link into javascript function. */
+       $(postElement).find(".show-source").each(function() {
+               $("a", this).click(function() {
+                       $(".post-text.text", getPostElement(this)).toggleClass("hidden");
+                       $(".post-text.raw-text", getPostElement(this)).toggleClass("hidden");
+                       return false;
+               });
+       });
        /* add “comment” link. */
        addCommentLink(getPostId(postElement), postElement, $(postElement).find(".post-status-line .time"));
  
                });
        });
  
+       /* process sender selection. */
+       $(".select-sender", postElement).css("display", "inline");
+       $(".sender", postElement).hide();
+       $(".select-sender button", postElement).click(function() {
+               $(".sender", postElement).show();
+               $(".select-sender", postElement).hide();
+               return false;
+       });
        /* mark everything as known on click. */
-       $(postElement).click(function() {
+       $(postElement).click(function(event) {
+               if ($(event.target).hasClass("click-to-show")) {
+                       return false;
+               }
                markPostAsKnown(this);
        });
  
  function ajaxifyReply(replyElement) {
        $(replyElement).find(".like-reply").submit(function() {
                likeReply(getReplyId(this));
-               markPostAsKnown(getPostElement(this));
                return false;
        });
        $(replyElement).find(".unlike-reply").submit(function() {
                unlikeReply(getReplyId(this));
-               markPostAsKnown(getPostElement(this));
                return false;
        });
        (function(replyElement) {
        })(replyElement);
        addCommentLink(getPostId(replyElement), replyElement, $(replyElement).find(".reply-status-line .time"));
  
-       /* mark post and all replies as known on click. */
-       $(replyElement).click(function() {
-               markPostAsKnown(getPostElement(this));
+       /* convert “show source” link into javascript function. */
+       $(replyElement).find(".show-reply-source").each(function() {
+               $("a", this).click(function() {
+                       $(".reply-text.text", getReplyElement(this)).toggleClass("hidden");
+                       $(".reply-text.raw-text", getReplyElement(this)).toggleClass("hidden");
+                       return false;
+               });
+       });
+       /* convert trust control buttons to javascript functions. */
+       $(replyElement).find(".reply-trust").submit(function() {
+               trustSone(getReplyAuthor(this));
+               return false;
+       });
+       $(replyElement).find(".reply-distrust").submit(function() {
+               distrustSone(getReplyAuthor(this));
+               return false;
+       });
+       $(replyElement).find(".reply-untrust").submit(function() {
+               untrustSone(getReplyAuthor(this));
+               return false;
        });
  }
  
   *            jQuery object representing the notification.
   */
  function ajaxifyNotification(notification) {
-       notification.find("form.dismiss").submit(function() {
+       notification.find("form").submit(function() {
                return false;
        });
+       notification.find("input[name=returnPage]").val($.url.attr("relative"));
+       if (notification.find(".short-text").length > 0) {
+               notification.find(".short-text").removeClass("hidden");
+               notification.find(".text").addClass("hidden");
+       }
+       notification.find("form.mark-as-read button").click(function() {
+               $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": $(":input[name=type]", this.form).val(), "id": $(":input[name=id]", this.form).val()});
+       });
+       notification.find("a[class^='link-']").each(function() {
+               linkElement = $(this);
+               if (linkElement.is("[href^='viewPost']")) {
+                       id = linkElement.attr("class").substr(5);
+                       if (hasPost(id)) {
+                               linkElement.attr("href", "#post-" + id);
+                       }
+               }
+       });
        notification.find("form.dismiss button").click(function() {
                $.getJSON("dismissNotification.ajax", { "formPassword" : getFormPassword(), "notification" : notification.attr("id") }, function(data, textStatus) {
                        /* dismiss in case of error, too. */
@@@ -566,13 -838,18 +838,18 @@@ function getStatus() 
                if ((data != null) && data.success) {
                        /* process Sone information. */
                        $.each(data.sones, function(index, value) {
-                               updateSoneStatus(value.id, value.name, value.status, value.modified, value.locked, value.lastUpdated);
+                               updateSoneStatus(value.id, value.name, value.status, value.modified, value.locked, value.lastUpdatedUnknown ? null : value.lastUpdated);
                        });
                        /* process notifications. */
                        $.each(data.notifications, function(index, value) {
                                oldNotification = $("#sone #notification-area .notification#" + value.id);
                                notification = ajaxifyNotification(createNotification(value.id, value.text, value.dismissable)).hide();
                                if (oldNotification.length != 0) {
+                                       if ((oldNotification.find(".short-text").length > 0) && (notification.find(".short-text").length > 0)) {
+                                               opened = oldNotification.is(":visible") && oldNotification.find(".short-text").hasClass("hidden");
+                                               notification.find(".short-text").toggleClass("hidden", opened);
+                                               notification.find(".text").toggleClass("hidden", !opened);
+                                       }
                                        oldNotification.replaceWith(notification.show());
                                } else {
                                        $("#sone #notification-area").append(notification);
                        });
                        /* process new posts. */
                        $.each(data.newPosts, function(index, value) {
-                               loadNewPost(value);
+                               loadNewPost(value.id, value.sone, value.recipient, value.time);
                        });
                        /* process new replies. */
                        $.each(data.newReplies, function(index, value) {
-                               loadNewReply(value);
+                               loadNewReply(value.id, value.sone, value.post, value.postSone);
                        });
                        /* do it again in 5 seconds. */
                        setTimeout(getStatus, 5000);
  }
  
  /**
+  * Returns the ID of the currently logged in Sone.
+  *
+  * @return The ID of the current Sone, or an empty string if no Sone is logged
+  *         in
+  */
+ function getCurrentSoneId() {
+       return $("#currentSoneId").text();
+ }
+ /**
   * Returns the content of the page-id attribute.
   *
   * @returns The page ID
@@@ -696,10 -983,20 +983,20 @@@ function hasReply(replyId) 
        return $("#sone .reply#" + replyId).length > 0;
  }
  
- function loadNewPost(postId) {
+ function loadNewPost(postId, soneId, recipientId, time) {
        if (hasPost(postId)) {
                return;
        }
+       if (!isIndexPage()) {
+               if (!isViewPostPage() || (getShownPostId() != postId)) {
+                       if (!isViewSonePage() || ((getShownSoneId() != soneId) && (getShownSoneId() != recipientId))) {
+                               return;
+                       }
+               }
+       }
+       if (getPostTime($("#sone .post").last()) > time) {
+               return;
+       }
        $.getJSON("getPost.ajax", { "post" : postId }, function(data, textStatus) {
                if ((data != null) && data.success) {
                        if (hasPost(data.post.id)) {
                        newPost = $(data.post.html).addClass("hidden");
                        if (firstOlderPost != null) {
                                newPost.insertBefore(firstOlderPost);
-                       } else {
-                               $("#sone #posts").append(newPost);
                        }
                        ajaxifyPost(newPost);
                        newPost.slideDown();
        });
  }
  
- function loadNewReply(replyId) {
+ function loadNewReply(replyId, soneId, postId, postSoneId) {
        if (hasReply(replyId)) {
                return;
        }
+       if (!hasPost(postId)) {
+               return;
+       }
        $.getJSON("getReply.ajax", { "reply": replyId }, function(data, textStatus) {
                /* find post. */
                if ((data != null) && data.success) {
        });
  }
  
+ /**
+  * Marks the given Sone as known if it is still new.
+  *
+  * @param soneElement
+  *            The Sone to mark as known
+  */
+ function markSoneAsKnown(soneElement) {
+       if ($(".new", soneElement).length > 0) {
+               $.getJSON("maskAsKnown.ajax", {"formPassword": getFormPassword(), "type": "sone", "id": getSoneId(soneElement)}, function(data, textStatus) {
+                       $(soneElement).removeClass("new");
+               });
+       }
+ }
  function markPostAsKnown(postElements) {
        $(postElements).each(function() {
                postElement = this;
                if ($(postElement).hasClass("new")) {
                        (function(postElement) {
-                               $.getJSON("markPostAsKnown.ajax", {"formPassword": getFormPassword(), "post": getPostId(postElement)}, function(data, textStatus) {
-                                       $(postElement).removeClass("new");
-                               });
+                               $(postElement).removeClass("new");
+                               $(".click-to-show", postElement).removeClass("new");
+                               $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "post", "id": getPostId(postElement)});
                        })(postElement);
                }
        });
@@@ -784,9 -1096,8 +1096,8 @@@ function markReplyAsKnown(replyElements
                replyElement = this;
                if ($(replyElement).hasClass("new")) {
                        (function(replyElement) {
-                               $.getJSON("markReplyAsKnown.ajax", {"formPassword": getFormPassword(), "reply": getReplyId(replyElement)}, function(data, textStatus) {
-                                       $(replyElement).removeClass("new");
-                               });
+                               $(replyElement).removeClass("new");
+                               $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "reply", "id": getReplyId(replyElement)});
                        })(replyElement);
                }
        });
  function resetActivity() {
        title = document.title;
        if (title.indexOf('(') == 0) {
-               document.title = title.substr(title.indexOf(' ') + 1);
+               setTitle(title.substr(title.indexOf(' ') + 1));
        }
  }
  
@@@ -803,12 -1114,65 +1114,65 @@@ function setActivity() 
        if (!focus) {
                title = document.title;
                if (title.indexOf('(') != 0) {
-                       document.title = "(!) " + title;
+                       setTitle("(!) " + title);
+               }
+               if (!iconBlinking) {
+                       setTimeout(toggleIcon, 1500);
+                       iconBlinking = true;
+               }
+       }
+ }
+ /**
+  * Sets the window title after a small delay to prevent race-condition issues.
+  *
+  * @param title
+  *            The title to set
+  */
+ function setTitle(title) {
+       setTimeout(function() {
+               document.title = title;
+       }, 50);
+ }
+ /** Whether the icon is currently showing activity. */
+ var iconActive = false;
+ /** Whether the icon is currently supposed to blink. */
+ var iconBlinking = false;
+ /**
+  * Toggles the icon. If the window has gained focus and the icon is still
+  * showing the activity state, it is returned to normal.
+  */
+ function toggleIcon() {
+       if (focus) {
+               if (iconActive) {
+                       changeIcon("images/icon.png");
+                       iconActive = false;
                }
+               iconBlinking = false;
+       } else {
+               iconActive = !iconActive;
+               console.log("showing icon: " + iconActive);
+               changeIcon(iconActive ? "images/icon-activity.png" : "images/icon.png");
+               setTimeout(toggleIcon, 1500);
        }
  }
  
  /**
+  * Changes the icon of the page.
+  *
+  * @param iconUrl
+  *            The new URL of the icon
+  */
+ function changeIcon(iconUrl) {
+       $("link[rel=icon]").remove();
+       $("head").append($("<link>").attr("rel", "icon").attr("type", "image/png").attr("href", iconUrl));
+       $("iframe[id=icon-update]")[0].src += "";
+ }
+ /**
   * Creates a new notification.
   *
   * @param id
@@@ -837,8 -1201,82 +1201,82 @@@ function createNotification(id, text, d
   *            The ID of the notification
   */
  function showNotificationDetails(notificationId) {
-       $("#sone .notification#" + notificationId + " .text").show();
-       $("#sone .notification#" + notificationId + " .short-text").hide();
+       $("#sone .notification#" + notificationId + " .text").removeClass("hidden");
+       $("#sone .notification#" + notificationId + " .short-text").addClass("hidden");
+ }
+ /**
+  * Deletes the field with the given ID from the profile.
+  *
+  * @param fieldId
+  *            The ID of the field to delete
+  */
+ function deleteProfileField(fieldId) {
+       $.getJSON("deleteProfileField.ajax", {"formPassword": getFormPassword(), "field": fieldId}, function(data, textStatus) {
+               if (data && data.success) {
+                       $("#sone .profile-field#" + data.field.id).slideUp();
+               }
+       });
+ }
+ /**
+  * Renames a profile field.
+  *
+  * @param fieldId
+  *            The ID of the field to rename
+  * @param newName
+  *            The new name of the field
+  * @param successFunction
+  *            Called when the renaming was successful
+  */
+ function editProfileField(fieldId, newName, successFunction) {
+       $.getJSON("editProfileField.ajax", {"formPassword": getFormPassword(), "field": fieldId, "name": newName}, function(data, textStatus) {
+               if (data && data.success) {
+                       successFunction();
+               }
+       });
+ }
+ /**
+  * Moves the profile field with the given ID one slot in the given direction.
+  *
+  * @param fieldId
+  *            The ID of the field to move
+  * @param direction
+  *            The direction to move in (“up” or “down”)
+  * @param successFunction
+  *            Function to call on success
+  */
+ function moveProfileField(fieldId, direction, successFunction) {
+       $.getJSON("moveProfileField.ajax", {"formPassword": getFormPassword(), "field": fieldId, "direction": direction}, function(data, textStatus) {
+               if (data && data.success) {
+                       successFunction();
+               }
+       });
+ }
+ /**
+  * Moves the profile field with the given ID up one slot.
+  *
+  * @param fieldId
+  *            The ID of the field to move
+  * @param successFunction
+  *            Function to call on success
+  */
+ function moveProfileFieldUp(fieldId, successFunction) {
+       moveProfileField(fieldId, "up", successFunction);
+ }
+ /**
+  * Moves the profile field with the given ID down one slot.
+  *
+  * @param fieldId
+  *            The ID of the field to move
+  * @param successFunction
+  *            Function to call on success
+  */
+ function moveProfileFieldDown(fieldId, successFunction) {
+       moveProfileField(fieldId, "down", successFunction);
  }
  
  //
@@@ -852,17 -1290,28 +1290,28 @@@ $(document).ready(function() 
        /* this initializes the status update input field. */
        getTranslation("WebInterface.DefaultText.StatusUpdate", function(defaultText) {
                registerInputTextareaSwap("#sone #update-status .status-input", defaultText, "text", false, false);
+               $("#sone #update-status .select-sender").css("display", "inline");
+               $("#sone #update-status .sender").hide();
+               $("#sone #update-status .select-sender button").click(function() {
+                       $("#sone #update-status .sender").show();
+                       $("#sone #update-status .select-sender").hide();
+                       return false;
+               });
                $("#sone #update-status").submit(function() {
                        if ($(this).find(":input.default:enabled").length > 0) {
                                return false;
                        }
-                       text = $(this).find(":input:enabled").val();
-                       $.getJSON("createPost.ajax", { "formPassword": getFormPassword(), "text": text }, function(data, textStatus) {
+                       sender = $(this).find(":input[name=sender]").val();
+                       text = $(this).find(":input[name=text]:enabled").val();
+                       $.getJSON("createPost.ajax", { "formPassword": getFormPassword(), "sender": sender, "text": text }, function(data, textStatus) {
                                if ((data != null) && data.success) {
-                                       loadNewPost(data.postId);
+                                       loadNewPost(data.postId, data.sone, data.recipient);
                                }
                        });
-                       $(this).find(":input:enabled").val("").blur();
+                       $(this).find(":input[name=sender]").val(getCurrentSoneId());
+                       $(this).find(":input[name=text]:enabled").val("").blur();
+                       $(this).find(".sender").hide();
+                       $(this).find(".select-sender").show();
                        return false;
                });
        });
        /* ajaxify input field on “view Sone” page. */
        getTranslation("WebInterface.DefaultText.Message", function(defaultText) {
                registerInputTextareaSwap("#sone #post-message input[name=text]", defaultText, "text", false, false);
+               $("#sone #post-message .select-sender").css("display", "inline");
+               $("#sone #post-message .sender").hide();
+               $("#sone #post-message .select-sender button").click(function() {
+                       $("#sone #post-message .sender").show();
+                       $("#sone #post-message .select-sender").hide();
+                       return false;
+               });
                $("#sone #post-message").submit(function() {
-                       text = $(this).find(":input:enabled").val();
-                       $.getJSON("createPost.ajax", { "formPassword": getFormPassword(), "recipient": getShownSoneId(), "text": text }, function(data, textStatus) {
+                       sender = $(this).find(":input[name=sender]").val();
+                       text = $(this).find(":input[name=text]:enabled").val();
+                       $.getJSON("createPost.ajax", { "formPassword": getFormPassword(), "recipient": getShownSoneId(), "sender": sender, "text": text }, function(data, textStatus) {
                                if ((data != null) && data.success) {
-                                       loadNewPost(data.postId);
+                                       loadNewPost(data.postId, getCurrentSoneId());
                                }
                        });
-                       $(this).find(":input:enabled").val("").blur();
+                       $(this).find(":input[name=sender]").val(getCurrentSoneId());
+                       $(this).find(":input[name=text]:enabled").val("").blur();
+                       $(this).find(".sender").hide();
+                       $(this).find(".select-sender").show();
                        return false;
                });
        });
  
 +      /* ajaxify album creation input field. */
 +      getTranslation("WebInterface.DefaultText.Reply", function(text) {
 +              $("#create-album input[type=text]").each(function() {
 +                      registerInputTextareaSwap(this, text, "name", false, true);
 +              });
 +      });
 +
        /* Ajaxifies all posts. */
        /* calling getTranslation here will cache the necessary values. */
        getTranslation("WebInterface.Confirmation.DeletePostButton", function(text) {
                });
        }
  
-       /*
-        * convert all “follow”, “unfollow”, “lock”, and “unlock” links to something
-        * nicer.
-        */
-       $("#sone .follow").submit(function() {
-               var followElement = this;
-               $.getJSON("followSone.ajax", { "sone": getSoneId(this), "formPassword": getFormPassword() }, function() {
-                       $(followElement).addClass("hidden");
-                       $(followElement).parent().find(".unfollow").removeClass("hidden");
-               });
-               return false;
-       });
-       $("#sone .unfollow").submit(function() {
-               var unfollowElement = this;
-               $.getJSON("unfollowSone.ajax", { "sone": getSoneId(this), "formPassword": getFormPassword() }, function() {
-                       $(unfollowElement).addClass("hidden");
-                       $(unfollowElement).parent().find(".follow").removeClass("hidden");
-               });
-               return false;
-       });
-       $("#sone .lock").submit(function() {
-               var lockElement = this;
-               $.getJSON("lockSone.ajax", { "sone" : getSoneId(this), "formPassword" : getFormPassword() }, function() {
-                       $(lockElement).addClass("hidden");
-                       $(lockElement).parent().find(".unlock").removeClass("hidden");
-               });
-               return false;
-       });
-       $("#sone .unlock").submit(function() {
-               var unlockElement = this;
-               $.getJSON("unlockSone.ajax", { "sone" : getSoneId(this), "formPassword" : getFormPassword() }, function() {
-                       $(unlockElement).addClass("hidden");
-                       $(unlockElement).parent().find(".lock").removeClass("hidden");
-               });
-               return false;
+       $("#sone .sone").each(function() {
+               ajaxifySone($(this));
        });
  
        /* process all existing notifications, ajaxify dismiss buttons. */
                <birth-day><% currentSone.profile.birthDay|xml></birth-day>
                <birth-month><% currentSone.profile.birthMonth|xml></birth-month>
                <birth-year><% currentSone.profile.birthYear|xml></birth-year>
+               <fields>
+                       <%foreach currentSone.profile.fields field>
+                       <field>
+                               <field-name><% field.name|xml></field-name>
+                               <field-value><% field.value|xml></field-value>
+                       </field>
+                       <%/foreach>
+               </fields>
        </profile>
  
        <posts>
                <%/foreach>
        </reply-likes>
  
 +      <albums>
 +              <%foreach currentSone.albums album>
 +              <%include insert/include/album.xml>
 +              <%/foreach>
 +      </albums>
 +
  </sone>