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

52 files changed:
pom.xml
src/main/java/net/pterodactylus/sone/core/Core.java
src/main/java/net/pterodactylus/sone/core/SoneInserter.java
src/main/java/net/pterodactylus/sone/data/Post.java
src/main/java/net/pterodactylus/sone/data/Reply.java
src/main/java/net/pterodactylus/sone/data/Sone.java
src/main/java/net/pterodactylus/sone/freenet/wot/DefaultOwnIdentity.java
src/main/java/net/pterodactylus/sone/freenet/wot/WebOfTrustConnector.java
src/main/java/net/pterodactylus/sone/main/SonePlugin.java
src/main/java/net/pterodactylus/sone/notify/ListNotification.java
src/main/java/net/pterodactylus/sone/notify/ListNotificationFilters.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/template/HttpRequestAccessor.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/template/NotificationManagerAccessor.java
src/main/java/net/pterodactylus/sone/template/ParserFilter.java
src/main/java/net/pterodactylus/sone/template/PostAccessor.java
src/main/java/net/pterodactylus/sone/template/ReplyGroupFilter.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/text/FreenetLinkParser.java
src/main/java/net/pterodactylus/sone/text/PartContainer.java
src/main/java/net/pterodactylus/sone/text/TemplatePart.java
src/main/java/net/pterodactylus/sone/web/IndexPage.java
src/main/java/net/pterodactylus/sone/web/KnownSonesPage.java
src/main/java/net/pterodactylus/sone/web/OptionsPage.java
src/main/java/net/pterodactylus/sone/web/SearchPage.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/web/SoneTemplatePage.java
src/main/java/net/pterodactylus/sone/web/ViewPostPage.java
src/main/java/net/pterodactylus/sone/web/ViewSonePage.java
src/main/java/net/pterodactylus/sone/web/WebInterface.java
src/main/java/net/pterodactylus/sone/web/ajax/GetStatusAjaxPage.java
src/main/java/net/pterodactylus/sone/web/ajax/GetTimesAjaxPage.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/web/page/FreenetTemplatePage.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/web/page/PageToadlet.java
src/main/java/net/pterodactylus/sone/web/page/RedirectPage.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/web/page/TemplatePage.java
src/main/resources/i18n/sone.en.properties
src/main/resources/static/css/sone.css
src/main/resources/static/images/sone-avatar.png [new file with mode: 0644]
src/main/resources/static/javascript/sone.js
src/main/resources/templates/about.html
src/main/resources/templates/editProfile.html
src/main/resources/templates/include/createSone.html
src/main/resources/templates/include/head.html
src/main/resources/templates/include/tail.html
src/main/resources/templates/include/viewPost.html
src/main/resources/templates/include/viewReply.html
src/main/resources/templates/notify/newPostNotification.html
src/main/resources/templates/notify/newReplyNotification.html
src/main/resources/templates/notify/newSoneNotification.html
src/main/resources/templates/options.html
src/main/resources/templates/search.html [new file with mode: 0644]
src/main/resources/templates/viewSone.html
src/main/resources/templates/xml/OpenSearch.xml [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/text/FreenetLinkParserTest.java [new file with mode: 0644]

diff --git a/pom.xml b/pom.xml
index 229f2ef..33fd6be 100644 (file)
--- a/pom.xml
+++ b/pom.xml
@@ -2,12 +2,12 @@
        <modelVersion>4.0.0</modelVersion>
        <groupId>net.pterodactylus</groupId>
        <artifactId>sone</artifactId>
-       <version>0.5.1</version>
+       <version>0.6.1</version>
        <dependencies>
                <dependency>
                        <groupId>net.pterodactylus</groupId>
                        <artifactId>utils</artifactId>
-                       <version>0.9.1</version>
+                       <version>0.9.3</version>
                </dependency>
                <dependency>
                        <groupId>junit</groupId>
index f5d695c..a67177b 100644 (file)
@@ -541,7 +541,8 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
         * @return {@code true} if the target Sone is trusted by the origin Sone
         */
        public boolean isSoneTrusted(Sone origin, Sone target) {
-               return trustedIdentities.containsKey(origin) && trustedIdentities.get(origin.getIdentity()).contains(target);
+               Validation.begin().isNotNull("Origin", origin).isNotNull("Target", target).check().isInstanceOf("Origin’s OwnIdentity", origin.getIdentity(), OwnIdentity.class).check();
+               return trustedIdentities.containsKey(origin.getIdentity()) && trustedIdentities.get(origin.getIdentity()).contains(target.getIdentity());
        }
 
        /**
@@ -549,7 +550,7 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
         *
         * @param postId
         *            The ID of the post to get
-        * @return The post, or {@code null} if there is no such post
+        * @return The post with the given ID, or a new post with the given ID
         */
        public Post getPost(String postId) {
                return getPost(postId, true);
@@ -591,6 +592,27 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
        }
 
        /**
+        * Returns all posts that have the given Sone as recipient.
+        *
+        * @see Post#getRecipient()
+        * @param recipient
+        *            The recipient of the posts
+        * @return All posts that have the given Sone as recipient
+        */
+       public Set<Post> getDirectedPosts(Sone recipient) {
+               Validation.begin().isNotNull("Recipient", recipient).check();
+               Set<Post> directedPosts = new HashSet<Post>();
+               synchronized (posts) {
+                       for (Post post : posts.values()) {
+                               if (recipient.equals(post.getRecipient())) {
+                                       directedPosts.add(post);
+                               }
+                       }
+               }
+               return directedPosts;
+       }
+
+       /**
         * Returns the reply with the given ID. If there is no reply with the given
         * ID yet, a new one is created.
         *
@@ -916,7 +938,6 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                                @SuppressWarnings("synthetic-access")
                                public void run() {
                                        if (!preferences.isSoneRescueMode()) {
-                                               soneDownloader.fetchSone(sone);
                                                return;
                                        }
                                        logger.log(Level.INFO, "Trying to restore Sone from Freenet…");
@@ -954,6 +975,8 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                        return null;
                }
                Sone sone = addLocalSone(ownIdentity);
+               sone.getOptions().addBooleanOption("AutoFollow", new DefaultOption<Boolean>(false));
+               saveSone(sone);
                return sone;
        }
 
@@ -983,6 +1006,11 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                                }
                                if (newSone) {
                                        coreListenerManager.fireNewSoneFound(sone);
+                                       for (Sone localSone : getLocalSones()) {
+                                               if (localSone.getOptions().getBooleanOption("AutoFollow").get()) {
+                                                       localSone.addFriend(sone.getId());
+                                               }
+                                       }
                                }
                        }
                        remoteSones.put(identity.getId(), sone);
@@ -1644,7 +1672,8 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                        posts.put(post.getId(), post);
                }
                synchronized (newPosts) {
-                       knownPosts.add(post.getId());
+                       newPosts.add(post.getId());
+                       coreListenerManager.fireNewPostFound(post);
                }
                sone.addPost(post);
                saveSone(sone);
@@ -1666,6 +1695,10 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                synchronized (posts) {
                        posts.remove(post.getId());
                }
+               synchronized (newPosts) {
+                       markPostKnown(post);
+                       knownPosts.remove(post.getId());
+               }
                saveSone(post.getSone());
        }
 
@@ -1768,7 +1801,8 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                        replies.put(reply.getId(), reply);
                }
                synchronized (newReplies) {
-                       knownReplies.add(reply.getId());
+                       newReplies.add(reply.getId());
+                       coreListenerManager.fireNewReplyFound(reply);
                }
                sone.addReply(reply);
                saveSone(sone);
@@ -1790,6 +1824,10 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                synchronized (replies) {
                        replies.remove(reply.getId());
                }
+               synchronized (newReplies) {
+                       markReplyKnown(reply);
+                       knownReplies.remove(reply.getId());
+               }
                sone.removeReply(reply);
                saveSone(sone);
        }
@@ -1996,6 +2034,7 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                try {
                        configuration.getIntValue("Option/ConfigurationVersion").setValue(0);
                        configuration.getIntValue("Option/InsertionDelay").setValue(options.getIntegerOption("InsertionDelay").getReal());
+                       configuration.getIntValue("Option/PostsPerPage").setValue(options.getIntegerOption("PostsPerPage").getReal());
                        configuration.getIntValue("Option/PositiveTrust").setValue(options.getIntegerOption("PositiveTrust").getReal());
                        configuration.getIntValue("Option/NegativeTrust").setValue(options.getIntegerOption("NegativeTrust").getReal());
                        configuration.getStringValue("Option/TrustComment").setValue(options.getStringOption("TrustComment").getReal());
@@ -2069,8 +2108,9 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                        }
 
                }));
+               options.addIntegerOption("PostsPerPage", new DefaultOption<Integer>(10));
                options.addIntegerOption("PositiveTrust", new DefaultOption<Integer>(75));
-               options.addIntegerOption("NegativeTrust", new DefaultOption<Integer>(-100));
+               options.addIntegerOption("NegativeTrust", new DefaultOption<Integer>(-25));
                options.addStringOption("TrustComment", new DefaultOption<String>("Set from Sone Web Interface"));
                options.addBooleanOption("SoneRescueMode", new DefaultOption<Boolean>(false));
                options.addBooleanOption("ClearOnNextRestart", new DefaultOption<Boolean>(false));
@@ -2088,6 +2128,7 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                }
 
                options.getIntegerOption("InsertionDelay").set(configuration.getIntValue("Option/InsertionDelay").getValue(null));
+               options.getIntegerOption("PostsPerPage").set(configuration.getIntValue("Option/PostsPerPage").getValue(null));
                options.getIntegerOption("PositiveTrust").set(configuration.getIntValue("Option/PositiveTrust").getValue(null));
                options.getIntegerOption("NegativeTrust").set(configuration.getIntValue("Option/NegativeTrust").getValue(null));
                options.getStringOption("TrustComment").set(configuration.getStringValue("Option/TrustComment").getValue(null));
@@ -2320,6 +2361,27 @@ public class Core implements IdentityListener, UpdateListener, ImageInsertListen
                }
 
                /**
+                * Returns the number of posts to show per page.
+                *
+                * @return The number of posts to show per page
+                */
+               public int getPostsPerPage() {
+                       return options.getIntegerOption("PostsPerPage").get();
+               }
+
+               /**
+                * Sets the number of posts to show per page.
+                *
+                * @param postsPerPage
+                *            The number of posts to show per page
+                * @return This preferences object
+                */
+               public Preferences setPostsPerPage(Integer postsPerPage) {
+                       options.getIntegerOption("PostsPerPage").set(postsPerPage);
+                       return this;
+               }
+
+               /**
                 * Returns the positive trust.
                 *
                 * @return The positive trust
index b16d374..15e054f 100644 (file)
@@ -160,7 +160,7 @@ public class SoneInserter extends AbstractService {
        protected void serviceRun() {
                long lastModificationTime = 0;
                String lastFingerprint = "";
-               while (!shouldStop()) {
+               while (!shouldStop()) { try {
                        /* check every seconds. */
                        sleep(1000);
 
@@ -236,7 +236,9 @@ public class SoneInserter extends AbstractService {
                                        }
                                }
                        }
-               }
+               } catch (Throwable t1) {
+                       logger.log(Level.SEVERE, "SoneInserter threw an Exception!", t1);
+               }}
        }
 
        /**
@@ -246,7 +248,7 @@ public class SoneInserter extends AbstractService {
         *
         * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
         */
-       private class InsertInformation {
+       private static class InsertInformation {
 
                /** All properties of the Sone, copied for thread safety. */
                private final Map<String, Object> soneProperties = new HashMap<String, Object>();
index 964a054..bb431d0 100644 (file)
@@ -20,6 +20,8 @@ package net.pterodactylus.sone.data;
 import java.util.Comparator;
 import java.util.UUID;
 
+import net.pterodactylus.util.filter.Filter;
+
 /**
  * A post is a short message that a user writes in his Sone to let other users
  * know what is going on.
@@ -38,6 +40,16 @@ public class Post {
 
        };
 
+       /** Filter for posts with timestamps from the future. */
+       public static final Filter<Post> FUTURE_POSTS_FILTER = new Filter<Post>() {
+
+               @Override
+               public boolean filterObject(Post post) {
+                       return post.getTime() <= System.currentTimeMillis();
+               }
+
+       };
+
        /** The GUID of the post. */
        private final UUID id;
 
index a106391..2dacfbe 100644 (file)
@@ -20,6 +20,8 @@ package net.pterodactylus.sone.data;
 import java.util.Comparator;
 import java.util.UUID;
 
+import net.pterodactylus.util.filter.Filter;
+
 /**
  * A reply is like a {@link Post} but can never be posted on its own, it always
  * refers to another {@link Post}.
@@ -38,6 +40,16 @@ public class Reply {
 
        };
 
+       /** Filter for replies with timestamps from the future. */
+       public static final Filter<Reply> FUTURE_REPLIES_FILTER = new Filter<Reply>() {
+
+               @Override
+               public boolean filterObject(Reply reply) {
+                       return reply.getTime() <= System.currentTimeMillis();
+               }
+
+       };
+
        /** The ID of the reply. */
        private final UUID id;
 
index 1a8c7df..aa4a155 100644 (file)
@@ -27,8 +27,10 @@ import java.util.Set;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
+import net.pterodactylus.sone.core.Options;
 import net.pterodactylus.sone.freenet.wot.Identity;
 import net.pterodactylus.sone.template.SoneAccessor;
+import net.pterodactylus.util.filter.Filter;
 import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.validation.Validation;
 import freenet.keys.FreenetURI;
@@ -57,6 +59,15 @@ public class Sone implements Fingerprintable, Comparable<Sone> {
 
        };
 
+       /** Filter to remove Sones that have not been downloaded. */
+       public static final Filter<Sone> EMPTY_SONE_FILTER = new Filter<Sone>() {
+
+               @Override
+               public boolean filterObject(Sone sone) {
+                       return sone.getTime() != 0;
+               }
+       };
+
        /** The logger. */
        private static final Logger logger = Logging.getLogger(Sone.class);
 
@@ -103,6 +114,9 @@ public class Sone implements Fingerprintable, Comparable<Sone> {
        /** The albums of this Sone. */
        private final List<Album> albums = Collections.synchronizedList(new ArrayList<Album>());
 
+       /** Sone-specific options. */
+       private final Options options = new Options();
+
        /**
         * Creates a new Sone.
         *
@@ -391,8 +405,10 @@ public class Sone implements Fingerprintable, Comparable<Sone> {
         * @return This Sone (for method chaining)
         */
        public synchronized Sone setPosts(Collection<Post> posts) {
-               this.posts.clear();
-               this.posts.addAll(posts);
+               synchronized (this) {
+                       this.posts.clear();
+                       this.posts.addAll(posts);
+               }
                return this;
        }
 
@@ -629,6 +645,15 @@ public class Sone implements Fingerprintable, Comparable<Sone> {
                albums.remove(album);
        }
 
+       /**
+        * Returns Sone-specific options.
+        *
+        * @return The options of this Sone
+        */
+       public Options getOptions() {
+               return options;
+       }
+
        //
        // FINGERPRINTABLE METHODS
        //
index ab96756..ab46b43 100644 (file)
@@ -167,4 +167,26 @@ public class DefaultOwnIdentity extends DefaultIdentity implements OwnIdentity {
                }
        }
 
+       //
+       // OBJECT METHODS
+       //
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public int hashCode() {
+               /* The hash of DefaultIdentity is fine. */
+               return super.hashCode();
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public boolean equals(Object object) {
+               /* The ID of the superclass is still enough. */
+               return super.equals(object);
+       }
+
 }
index b6d68d3..59da039 100644 (file)
@@ -42,7 +42,7 @@ public class WebOfTrustConnector implements ConnectorListener {
        private static final Logger logger = Logging.getLogger(WebOfTrustConnector.class);
 
        /** The name of the WoT plugin. */
-       private static final String WOT_PLUGIN_NAME = "plugins.WoT.WoT";
+       private static final String WOT_PLUGIN_NAME = "plugins.WebOfTrust.WebOfTrust";
 
        /** A random connection identifier. */
        private static final String PLUGIN_CONNECTION_IDENTIFIER = "Sone-WoT-Connector-" + Math.abs(Math.random());
@@ -230,7 +230,7 @@ public class WebOfTrustConnector implements ConnectorListener {
         *             if an error occured talking to the Web of Trust plugin
         */
        public Trust getTrust(OwnIdentity ownIdentity, Identity identity) throws PluginException {
-               Reply getTrustReply = performRequest(SimpleFieldSetConstructor.create().put("Message", "GetIdentity").put("TreeOwner", ownIdentity.getId()).put("Identity", identity.getId()).get());
+               Reply getTrustReply = performRequest(SimpleFieldSetConstructor.create().put("Message", "GetIdentity").put("Truster", ownIdentity.getId()).put("Identity", identity.getId()).get());
                String trust = getTrustReply.getFields().get("Trust");
                String score = getTrustReply.getFields().get("Score");
                String rank = getTrustReply.getFields().get("Rank");
index 0f7f0e8..de4fee8 100644 (file)
@@ -78,7 +78,7 @@ public class SonePlugin implements FredPlugin, FredPluginL10n, FredPluginBaseL10
        }
 
        /** The version. */
-       public static final Version VERSION = new Version(0, 5, 1);
+       public static final Version VERSION = new Version(0, 6, 1);
 
        /** The logger. */
        private static final Logger logger = Logging.getLogger(SonePlugin.class);
index c7e5b7c..c66d376 100644 (file)
@@ -18,8 +18,9 @@
 package net.pterodactylus.sone.notify;
 
 import java.util.ArrayList;
-import java.util.Collections;
+import java.util.Collection;
 import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 
 import net.pterodactylus.util.notify.TemplateNotification;
 import net.pterodactylus.util.template.Template;
@@ -33,8 +34,11 @@ import net.pterodactylus.util.template.Template;
  */
 public class ListNotification<T> extends TemplateNotification {
 
+       /** The key under which to store the elements in the template. */
+       private final String key;
+
        /** The list of new elements. */
-       private final List<T> elements = Collections.synchronizedList(new ArrayList<T>());
+       private final List<T> elements = new CopyOnWriteArrayList<T>();
 
        /**
         * Creates a new list notification.
@@ -47,10 +51,42 @@ public class ListNotification<T> extends TemplateNotification {
         *            The template to render
         */
        public ListNotification(String id, String key, Template template) {
-               super(id, template);
+               this(id, key, template, true);
+       }
+
+       /**
+        * Creates a new list notification.
+        *
+        * @param id
+        *            The ID of the notification
+        * @param key
+        *            The key under which to store the elements in the template
+        * @param template
+        *            The template to render
+        * @param dismissable
+        *            {@code true} if this notification should be dismissable by the
+        *            user, {@code false} otherwise
+        */
+       public ListNotification(String id, String key, Template template, boolean dismissable) {
+               super(id, System.currentTimeMillis(), System.currentTimeMillis(), dismissable, template);
+               this.key = key;
                template.getInitialContext().set(key, elements);
        }
 
+       /**
+        * Creates a new list notification that copies its ID and the template from
+        * the given list notification.
+        *
+        * @param listNotification
+        *            The list notification to copy
+        */
+       public ListNotification(ListNotification<T> listNotification) {
+               super(listNotification.getId(), listNotification.getCreatedTime(), listNotification.getLastUpdatedTime(), listNotification.isDismissable(), new Template());
+               this.key = listNotification.key;
+               getTemplate().add(listNotification.getTemplate());
+               getTemplate().getInitialContext().set(key, elements);
+       }
+
        //
        // ACTIONS
        //
@@ -65,6 +101,18 @@ public class ListNotification<T> extends TemplateNotification {
        }
 
        /**
+        * Sets the elements to show in this notification.
+        *
+        * @param elements
+        *            The elements to show
+        */
+       public void setElements(Collection<? extends T> elements) {
+               this.elements.clear();
+               this.elements.addAll(elements);
+               touch();
+       }
+
+       /**
         * Returns whether there are any new elements.
         *
         * @return {@code true} if there are no new elements, {@code false} if there
diff --git a/src/main/java/net/pterodactylus/sone/notify/ListNotificationFilters.java b/src/main/java/net/pterodactylus/sone/notify/ListNotificationFilters.java
new file mode 100644 (file)
index 0000000..a6ef8aa
--- /dev/null
@@ -0,0 +1,169 @@
+/*
+ * Sone - ListNotificationFilters.java - Copyright © 2010 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.notify;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.Reply;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.util.notify.Notification;
+
+/**
+ * Filter for {@link ListNotification}s.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class ListNotificationFilters {
+
+       /**
+        * Filters new-post and new-reply notifications in the given list of
+        * notifications. If {@code currentSone} is <code>null</code>, new-post and
+        * new-reply notifications are removed completely. If {@code currentSone} is
+        * not {@code null}, only posts that are posted by a friend Sone or the Sone
+        * itself, and replies that are replies to posts of friend Sones or the Sone
+        * itself will be retained in the notifications.
+        *
+        * @param notifications
+        *            The notifications to filter
+        * @param currentSone
+        *            The current Sone, or {@code null} if not logged in
+        * @return The filtered notifications
+        */
+       public static List<Notification> filterNotifications(List<Notification> notifications, Sone currentSone) {
+               ListNotification<Post> newPostNotification = getNotification(notifications, "new-post-notification", Post.class);
+               if (newPostNotification != null) {
+                       ListNotification<Post> filteredNotification = filterNewPostNotification(newPostNotification, currentSone);
+                       int notificationIndex = notifications.indexOf(newPostNotification);
+                       if (filteredNotification == null) {
+                               notifications.remove(notificationIndex);
+                       } else {
+                               notifications.set(notificationIndex, filteredNotification);
+                       }
+               }
+               ListNotification<Reply> newReplyNotification = getNotification(notifications, "new-replies-notification", Reply.class);
+               if (newReplyNotification != null) {
+                       ListNotification<Reply> filteredNotification = filterNewReplyNotification(newReplyNotification, currentSone);
+                       int notificationIndex = notifications.indexOf(newReplyNotification);
+                       if (filteredNotification == null) {
+                               notifications.remove(notificationIndex);
+                       } else {
+                               notifications.set(notificationIndex, filteredNotification);
+                       }
+               }
+               return notifications;
+       }
+
+       /**
+        * Filters the new posts of the given notification. If {@code currentSone}
+        * is {@code null}, {@code null} is returned and the notification is
+        * subsequently removed. Otherwise only posts that are posted by friend
+        * Sones of the given Sone are retained; all other posts are removed.
+        *
+        * @param newPostNotification
+        *            The new-post notification
+        * @param currentSone
+        *            The current Sone, or {@code null} if not logged in
+        * @return The filtered new-post notification, or {@code null} if the
+        *         notification should be removed
+        */
+       private static ListNotification<Post> filterNewPostNotification(ListNotification<Post> newPostNotification, Sone currentSone) {
+               if (currentSone == null) {
+                       return null;
+               }
+               List<Post> newPosts = new ArrayList<Post>();
+               for (Post post : newPostNotification.getElements()) {
+                       if (currentSone.hasFriend(post.getSone().getId()) || currentSone.equals(post.getSone()) || currentSone.equals(post.getRecipient())) {
+                               newPosts.add(post);
+                       }
+               }
+               if (newPosts.isEmpty()) {
+                       return null;
+               }
+               if (newPosts.size() == newPostNotification.getElements().size()) {
+                       return newPostNotification;
+               }
+               ListNotification<Post> filteredNotification = new ListNotification<Post>(newPostNotification);
+               filteredNotification.setElements(newPosts);
+               return filteredNotification;
+       }
+
+       /**
+        * Filters the new replies of the given notification. If {@code currentSone}
+        * is {@code null}, {@code null} is returned and the notification is
+        * subsequently removed. Otherwise only replies that are replies to posts
+        * that are posted by friend Sones of the given Sone are retained; all other
+        * replies are removed.
+        *
+        * @param newReplyNotification
+        *            The new-reply notification
+        * @param currentSone
+        *            The current Sone, or {@code null} if not logged in
+        * @return The filtered new-reply notification, or {@code null} if the
+        *         notification should be removed
+        */
+       private static ListNotification<Reply> filterNewReplyNotification(ListNotification<Reply> newReplyNotification, Sone currentSone) {
+               if (currentSone == null) {
+                       return null;
+               }
+               List<Reply> newReplies = new ArrayList<Reply>();
+               for (Reply reply : newReplyNotification.getElements()) {
+                       if (((reply.getPost().getSone() != null) && currentSone.hasFriend(reply.getPost().getSone().getId())) || currentSone.equals(reply.getPost().getSone()) || currentSone.equals(reply.getPost().getRecipient())) {
+                               newReplies.add(reply);
+                       }
+               }
+               if (newReplies.isEmpty()) {
+                       return null;
+               }
+               if (newReplies.size() == newReplyNotification.getElements().size()) {
+                       return newReplyNotification;
+               }
+               ListNotification<Reply> filteredNotification = new ListNotification<Reply>(newReplyNotification);
+               filteredNotification.setElements(newReplies);
+               return filteredNotification;
+       }
+
+       /**
+        * Finds the notification with the given ID in the list of notifications and
+        * returns it.
+        *
+        * @param <T>
+        *            The type of the item in the notification
+        * @param notifications
+        *            The notification to search
+        * @param notificationId
+        *            The ID of the requested notification
+        * @param notificationElementClass
+        *            The class of the notification item
+        * @return The requested notification, or {@code null} if no notification
+        *         with the given ID could be found
+        */
+       @SuppressWarnings("unchecked")
+       private static <T> ListNotification<T> getNotification(Collection<? extends Notification> notifications, String notificationId, Class<T> notificationElementClass) {
+               for (Notification notification : notifications) {
+                       if (!notificationId.equals(notification.getId())) {
+                               continue;
+                       }
+                       return (ListNotification<T>) notification;
+               }
+               return null;
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/template/HttpRequestAccessor.java b/src/main/java/net/pterodactylus/sone/template/HttpRequestAccessor.java
new file mode 100644 (file)
index 0000000..c5e38df
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * Sone - HttpRequestAccessor.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 net.pterodactylus.util.template.Accessor;
+import net.pterodactylus.util.template.ReflectionAccessor;
+import net.pterodactylus.util.template.TemplateContext;
+import freenet.support.api.HTTPRequest;
+
+/**
+ * {@link Accessor} implementation that can parse headers from
+ * {@link HTTPRequest}s.
+ *
+ * @see HTTPRequest#getHeader(String)
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class HttpRequestAccessor extends ReflectionAccessor {
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public Object get(TemplateContext templateContext, Object object, String member) {
+               Object parentValue = super.get(templateContext, object, member);
+               if (parentValue != null) {
+                       return parentValue;
+               }
+               HTTPRequest httpRequest = (HTTPRequest) object;
+               return httpRequest.getHeader(member);
+       }
+
+}
index a8f34a8..c4468ea 100644 (file)
@@ -21,6 +21,8 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.notify.ListNotificationFilters;
 import net.pterodactylus.util.notify.Notification;
 import net.pterodactylus.util.notify.NotificationManager;
 import net.pterodactylus.util.template.ReflectionAccessor;
@@ -47,13 +49,9 @@ public class NotificationManagerAccessor extends ReflectionAccessor {
        public Object get(TemplateContext templateContext, Object object, String member) {
                NotificationManager notificationManager = (NotificationManager) object;
                if ("all".equals(member)) {
-                       List<Notification> notifications = new ArrayList<Notification>(notificationManager.getNotifications());
+                       List<Notification> notifications = ListNotificationFilters.filterNotifications(new ArrayList<Notification>(notificationManager.getNotifications()), (Sone) templateContext.get("currentSone"));
                        Collections.sort(notifications, Notification.CREATED_TIME_SORTER);
                        return notifications;
-               } else if ("new".equals(member)) {
-                       List<Notification> notifications = new ArrayList<Notification>(notificationManager.getChangedNotifications());
-                       Collections.sort(notifications, Notification.LAST_UPDATED_TIME_SORTER);
-                       return notifications;
                }
                return super.get(templateContext, object, member);
        }
index 1e274cc..477e972 100644 (file)
@@ -53,7 +53,7 @@ public class ParserFilter implements Filter {
         */
        public ParserFilter(Core core, TemplateContextFactory templateContextFactory) {
                this.core = core;
-               linkParser = new FreenetLinkParser(templateContextFactory);
+               linkParser = new FreenetLinkParser(core, templateContextFactory);
        }
 
        /**
index ca0c84a..99d6845 100644 (file)
@@ -19,7 +19,9 @@ package net.pterodactylus.sone.template;
 
 import net.pterodactylus.sone.core.Core;
 import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.Reply;
 import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.util.filter.Filters;
 import net.pterodactylus.util.template.ReflectionAccessor;
 import net.pterodactylus.util.template.TemplateContext;
 
@@ -54,7 +56,7 @@ public class PostAccessor extends ReflectionAccessor {
        public Object get(TemplateContext templateContext, Object object, String member) {
                Post post = (Post) object;
                if ("replies".equals(member)) {
-                       return core.getReplies(post);
+                       return Filters.filteredList(core.getReplies(post), Reply.FUTURE_REPLIES_FILTER);
                } else if (member.equals("likes")) {
                        return core.getLikes(post);
                } else if (member.equals("liked")) {
diff --git a/src/main/java/net/pterodactylus/sone/template/ReplyGroupFilter.java b/src/main/java/net/pterodactylus/sone/template/ReplyGroupFilter.java
new file mode 100644 (file)
index 0000000..2567284
--- /dev/null
@@ -0,0 +1,78 @@
+/*
+ * Sone - ReplyGroupFilter.java - Copyright © 2010 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.template;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.Reply;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.util.template.Filter;
+import net.pterodactylus.util.template.TemplateContext;
+
+/**
+ * {@link Filter} implementation that groups replies by the post the are in
+ * reply to, returning a map with the post as key and the list of replies as
+ * values.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class ReplyGroupFilter implements Filter {
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public Object format(TemplateContext templateContext, Object data, Map<String, String> parameters) {
+               @SuppressWarnings("unchecked")
+               List<Reply> allReplies = (List<Reply>) data;
+               Map<Post, Set<Sone>> postSones = new HashMap<Post, Set<Sone>>();
+               Map<Post, Set<Reply>> postReplies = new HashMap<Post, Set<Reply>>();
+               for (Reply reply : allReplies) {
+                       Post post = reply.getPost();
+                       Set<Sone> sones = postSones.get(post);
+                       if (sones == null) {
+                               sones = new HashSet<Sone>();
+                               postSones.put(post, sones);
+                       }
+                       sones.add(reply.getSone());
+                       Set<Reply> replies = postReplies.get(post);
+                       if (replies == null) {
+                               replies = new HashSet<Reply>();
+                               postReplies.put(post, replies);
+                       }
+                       replies.add(reply);
+               }
+               Map<Post, Map<String, Set<?>>> result = new HashMap<Post, Map<String, Set<?>>>();
+               for (Post post : postSones.keySet()) {
+                       if (result.containsKey(post)) {
+                               continue;
+                       }
+                       Map<String, Set<?>> postResult = new HashMap<String, Set<?>>();
+                       postResult.put("sones", postSones.get(post));
+                       postResult.put("replies", postReplies.get(post));
+                       result.put(post, postResult);
+               }
+               return result;
+       }
+
+}
index 0a50a62..7c30b76 100644 (file)
@@ -27,6 +27,10 @@ import java.util.logging.Logger;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import net.pterodactylus.sone.core.Core;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.template.SoneAccessor;
 import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.template.TemplateContextFactory;
 import net.pterodactylus.util.template.TemplateParser;
@@ -68,20 +72,32 @@ public class FreenetLinkParser implements Parser<FreenetLinkParserContext> {
                HTTP,
 
                /** Link is HTTPS. */
-               HTTPS;
+               HTTPS,
+
+               /** Link is a Sone. */
+               SONE,
+
+               /** Link is a post. */
+               POST,
 
        }
 
+       /** The core. */
+       private final Core core;
+
        /** The template factory. */
        private final TemplateContextFactory templateContextFactory;
 
        /**
         * Creates a new freenet link parser.
         *
+        * @param core
+        *            The core
         * @param templateContextFactory
         *            The template context factory
         */
-       public FreenetLinkParser(TemplateContextFactory templateContextFactory) {
+       public FreenetLinkParser(Core core, TemplateContextFactory templateContextFactory) {
+               this.core = core;
                this.templateContextFactory = templateContextFactory;
        }
 
@@ -97,8 +113,20 @@ public class FreenetLinkParser implements Parser<FreenetLinkParserContext> {
                PartContainer parts = new PartContainer();
                BufferedReader bufferedReader = (source instanceof BufferedReader) ? (BufferedReader) source : new BufferedReader(source);
                String line;
+               boolean lastLineEmpty = true;
+               int emptyLines = 0;
                while ((line = bufferedReader.readLine()) != null) {
-                       line = line.trim() + "\n";
+                       if (line.trim().length() == 0) {
+                               if (lastLineEmpty) {
+                                       continue;
+                               }
+                               parts.add(createPlainTextPart("\n"));
+                               ++emptyLines;
+                               lastLineEmpty = emptyLines == 2;
+                               continue;
+                       }
+                       emptyLines = 0;
+                       boolean lineComplete = true;
                        while (line.length() > 0) {
                                int nextKsk = line.indexOf("KSK@");
                                int nextChk = line.indexOf("CHK@");
@@ -106,8 +134,14 @@ public class FreenetLinkParser implements Parser<FreenetLinkParserContext> {
                                int nextUsk = line.indexOf("USK@");
                                int nextHttp = line.indexOf("http://");
                                int nextHttps = line.indexOf("https://");
-                               if ((nextKsk == -1) && (nextChk == -1) && (nextSsk == -1) && (nextUsk == -1) && (nextHttp == -1) && (nextHttps == -1)) {
-                                       parts.add(createPlainTextPart(line));
+                               int nextSone = line.indexOf("sone://");
+                               int nextPost = line.indexOf("post://");
+                               if ((nextKsk == -1) && (nextChk == -1) && (nextSsk == -1) && (nextUsk == -1) && (nextHttp == -1) && (nextHttps == -1) && (nextSone == -1) && (nextPost == -1)) {
+                                       if (lineComplete && !lastLineEmpty) {
+                                               parts.add(createPlainTextPart("\n" + line));
+                                       } else {
+                                               parts.add(createPlainTextPart(line));
+                                       }
                                        break;
                                }
                                int next = Integer.MAX_VALUE;
@@ -136,6 +170,14 @@ public class FreenetLinkParser implements Parser<FreenetLinkParserContext> {
                                        next = nextHttps;
                                        linkType = LinkType.HTTPS;
                                }
+                               if ((nextSone > -1) && (nextSone < next)) {
+                                       next = nextSone;
+                                       linkType = LinkType.SONE;
+                               }
+                               if ((nextPost > -1) && (nextPost < next)) {
+                                       next = nextPost;
+                                       linkType = LinkType.POST;
+                               }
                                if ((next >= 8) && (line.substring(next - 8, next).equals("freenet:"))) {
                                        next -= 8;
                                        line = line.substring(0, next) + line.substring(next + 8);
@@ -143,7 +185,11 @@ public class FreenetLinkParser implements Parser<FreenetLinkParserContext> {
                                Matcher matcher = whitespacePattern.matcher(line);
                                int nextSpace = matcher.find(next) ? matcher.start() : line.length();
                                if (nextSpace > (next + 4)) {
-                                       parts.add(createPlainTextPart(line.substring(0, next)));
+                                       if (!lastLineEmpty && lineComplete) {
+                                               parts.add(createPlainTextPart("\n" + line.substring(0, next)));
+                                       } else {
+                                               parts.add(createPlainTextPart(line.substring(0, next)));
+                                       }
                                        String link = line.substring(next, nextSpace);
                                        String name = link;
                                        logger.log(Level.FINER, "Found link: %s", link);
@@ -196,13 +242,44 @@ public class FreenetLinkParser implements Parser<FreenetLinkParserContext> {
                                                }
                                                link = "?_CHECKED_HTTP_=" + link;
                                                parts.add(createInternetLinkPart(link, name));
+                                       } else if (linkType == LinkType.SONE) {
+                                               String soneId = link.substring(7);
+                                               Sone sone = core.getSone(soneId, false);
+                                               if (sone != null) {
+                                                       parts.add(createInSoneLinkPart("viewSone.html?sone=" + soneId, SoneAccessor.getNiceName(sone)));
+                                               } else {
+                                                       parts.add(createPlainTextPart(link));
+                                               }
+                                       } else if (linkType == LinkType.POST) {
+                                               String postId = link.substring(7);
+                                               Post post = core.getPost(postId, false);
+                                               if (post != null) {
+                                                       String postText = post.getText();
+                                                       postText = postText.substring(0, Math.min(postText.length(), 20)) + "…";
+                                                       Sone postSone = post.getSone();
+                                                       parts.add(createInSoneLinkPart("viewPost.html?post=" + postId, postText, (postSone == null) ? postText : SoneAccessor.getNiceName(post.getSone())));
+                                               } else {
+                                                       parts.add(createPlainTextPart(link));
+                                               }
                                        }
                                        line = line.substring(nextSpace);
                                } else {
-                                       parts.add(createPlainTextPart(line.substring(0, next + 4)));
+                                       if (!lastLineEmpty && lineComplete) {
+                                               parts.add(createPlainTextPart("\n" + line.substring(0, next + 4)));
+                                       } else {
+                                               parts.add(createPlainTextPart(line.substring(0, next + 4)));
+                                       }
                                        line = line.substring(next + 4);
                                }
+                               lineComplete = false;
                        }
+                       lastLineEmpty = false;
+               }
+               for (int partIndex = parts.size() - 1; partIndex >= 0; --partIndex) {
+                       if (!parts.getPart(partIndex).toString().equals("\n")) {
+                               break;
+                       }
+                       parts.removePart(partIndex);
                }
                return parts;
        }
@@ -264,4 +341,32 @@ public class FreenetLinkParser implements Parser<FreenetLinkParserContext> {
                return new TemplatePart(templateContextFactory, TemplateParser.parse(new StringReader("<a class=\"freenet-trusted\" href=\"/<% link|html>\" title=\"<% link|html>\"><% name|html></a>"))).set("link", link).set("name", name);
        }
 
+       /**
+        * Creates a new part based on a template that links to a page in Sone.
+        *
+        * @param link
+        *            The target of the link
+        * @param name
+        *            The name of the link
+        * @return The part that displays the link
+        */
+       private Part createInSoneLinkPart(String link, String name) {
+               return createInSoneLinkPart(link, name, name);
+       }
+
+       /**
+        * Creates a new part based on a template that links to a page in Sone.
+        *
+        * @param link
+        *            The target of the link
+        * @param name
+        *            The name of the link
+        * @param title
+        *            The title attribute of the link
+        * @return The part that displays the link
+        */
+       private Part createInSoneLinkPart(String link, String name, String title) {
+               return new TemplatePart(templateContextFactory, TemplateParser.parse(new StringReader("<a class=\"in-sone\" href=\"<%link|html>\" title=\"<%title|html>\"><%name|html></a>"))).set("link", link).set("name", name).set("title", title);
+       }
+
 }
index 7399859..d52658e 100644 (file)
@@ -18,6 +18,7 @@
 package net.pterodactylus.sone.text;
 
 import java.io.IOException;
+import java.io.StringWriter;
 import java.io.Writer;
 import java.util.ArrayList;
 import java.util.List;
@@ -48,6 +49,36 @@ public class PartContainer implements Part {
                parts.add(part);
        }
 
+       /**
+        * Returns the part at the given index.
+        *
+        * @param index
+        *            The index of the part
+        * @return The part
+        */
+       public Part getPart(int index) {
+               return parts.get(index);
+       }
+
+       /**
+        * Removes the part at the given index.
+        *
+        * @param index
+        *            The index of the part to remove
+        */
+       public void removePart(int index) {
+               parts.remove(index);
+       }
+
+       /**
+        * Returns the number of parts.
+        *
+        * @return The number of parts
+        */
+       public int size() {
+               return parts.size();
+       }
+
        //
        // PART METHODS
        //
@@ -62,4 +93,22 @@ public class PartContainer implements Part {
                }
        }
 
+       //
+       // OBJECT METHODS
+       //
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public String toString() {
+               StringWriter stringWriter = new StringWriter();
+               try {
+                       render(stringWriter);
+               } catch (IOException ioe1) {
+                       /* should never throw, ignore. */
+               }
+               return stringWriter.toString();
+       }
+
 }
index 1ac1fdd..ac5694c 100644 (file)
@@ -18,6 +18,7 @@
 package net.pterodactylus.sone.text;
 
 import java.io.IOException;
+import java.io.StringWriter;
 import java.io.Writer;
 
 import net.pterodactylus.util.template.Template;
@@ -89,4 +90,22 @@ public class TemplatePart implements Part, net.pterodactylus.util.template.Part
                template.render(templateContext.mergeContext(template.getInitialContext()), writer);
        }
 
+       //
+       // OBJECT METHODS
+       //
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public String toString() {
+               StringWriter stringWriter = new StringWriter();
+               try {
+                       render(stringWriter);
+               } catch (IOException ioe1) {
+                       /* should never throw, ignore. */
+               }
+               return stringWriter.toString();
+       }
+
 }
index 2d85e02..e675311 100644 (file)
@@ -22,9 +22,9 @@ import java.util.Collections;
 import java.util.List;
 
 import net.pterodactylus.sone.data.Post;
-import net.pterodactylus.sone.data.Reply;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.util.collection.Pagination;
+import net.pterodactylus.util.filter.Filters;
 import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
@@ -73,25 +73,11 @@ public class IndexPage extends SoneTemplatePage {
                                }
                        }
                }
+               allPosts = Filters.filteredList(allPosts, Post.FUTURE_POSTS_FILTER);
                Collections.sort(allPosts, Post.TIME_COMPARATOR);
-               Pagination<Post> pagination = new Pagination<Post>(allPosts, 25).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("page"), 0));
+               Pagination<Post> pagination = new Pagination<Post>(allPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("page"), 0));
                templateContext.set("pagination", pagination);
                templateContext.set("posts", pagination.getItems());
        }
 
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       protected void postProcess(Request request, TemplateContext templateContext) {
-               @SuppressWarnings("unchecked")
-               List<Post> posts = (List<Post>) templateContext.get("posts");
-               for (Post post : posts) {
-                       webInterface.getCore().markPostKnown(post);
-                       for (Reply reply : webInterface.getCore().getReplies(post)) {
-                               webInterface.getCore().markReplyKnown(reply);
-                       }
-               }
-       }
-
 }
index b08b6e3..7c8f9ad 100644 (file)
@@ -23,6 +23,7 @@ import java.util.List;
 
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.util.collection.Pagination;
+import net.pterodactylus.util.filter.Filters;
 import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
@@ -56,24 +57,11 @@ public class KnownSonesPage extends SoneTemplatePage {
        @Override
        protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
                super.processTemplate(request, templateContext);
-               List<Sone> knownSones = new ArrayList<Sone>(webInterface.getCore().getSones());
+               List<Sone> knownSones = Filters.filteredList(new ArrayList<Sone>(webInterface.getCore().getSones()), Sone.EMPTY_SONE_FILTER);
                Collections.sort(knownSones, Sone.NICE_NAME_COMPARATOR);
                Pagination<Sone> sonePagination = new Pagination<Sone>(knownSones, 25).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("page"), 0));
                templateContext.set("pagination", sonePagination);
                templateContext.set("knownSones", sonePagination.getItems());
        }
 
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       protected void postProcess(Request request, TemplateContext templateContext) {
-               super.postProcess(request, templateContext);
-               @SuppressWarnings("unchecked")
-               List<Sone> sones = (List<Sone>) templateContext.get("knownSones");
-               for (Sone sone : sones) {
-                       webInterface.getCore().markSoneKnown(sone);
-               }
-       }
-
 }
index 84d7c79..39cacc6 100644 (file)
@@ -18,6 +18,7 @@
 package net.pterodactylus.sone.web;
 
 import net.pterodactylus.sone.core.Core.Preferences;
+import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.web.page.Page.Request.Method;
 import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
@@ -53,9 +54,17 @@ public class OptionsPage extends SoneTemplatePage {
        protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
                super.processTemplate(request, templateContext);
                Preferences preferences = webInterface.getCore().getPreferences();
+               Sone currentSone = webInterface.getCurrentSone(request.getToadletContext(), false);
                if (request.getMethod() == Method.POST) {
+                       if (currentSone != null) {
+                               boolean autoFollow = request.getHttpRequest().isPartSet("auto-follow");
+                               currentSone.getOptions().getBooleanOption("AutoFollow").set(autoFollow);
+                               webInterface.getCore().saveSone(currentSone);
+                       }
                        Integer insertionDelay = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("insertion-delay", 16));
                        preferences.setInsertionDelay(insertionDelay);
+                       Integer postsPerPage = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("posts-per-page", 4), null);
+                       preferences.setPostsPerPage(postsPerPage);
                        Integer positiveTrust = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("positive-trust", 3));
                        preferences.setPositiveTrust(positiveTrust);
                        Integer negativeTrust = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("negative-trust", 4));
@@ -74,7 +83,11 @@ public class OptionsPage extends SoneTemplatePage {
                        webInterface.getCore().saveConfiguration();
                        throw new RedirectException(getPath());
                }
+               if (currentSone != null) {
+                       templateContext.set("auto-follow", currentSone.getOptions().getBooleanOption("AutoFollow").get());
+               }
                templateContext.set("insertion-delay", preferences.getInsertionDelay());
+               templateContext.set("posts-per-page", preferences.getPostsPerPage());
                templateContext.set("positive-trust", preferences.getPositiveTrust());
                templateContext.set("negative-trust", preferences.getNegativeTrust());
                templateContext.set("trust-comment", preferences.getTrustComment());
diff --git a/src/main/java/net/pterodactylus/sone/web/SearchPage.java b/src/main/java/net/pterodactylus/sone/web/SearchPage.java
new file mode 100644 (file)
index 0000000..1d1aa36
--- /dev/null
@@ -0,0 +1,484 @@
+/*
+ * Sone - OptionsPage.java - Copyright © 2010 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.web;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+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.util.collection.Converter;
+import net.pterodactylus.util.collection.Converters;
+import net.pterodactylus.util.collection.Pagination;
+import net.pterodactylus.util.filter.Filter;
+import net.pterodactylus.util.filter.Filters;
+import net.pterodactylus.util.number.Numbers;
+import net.pterodactylus.util.template.Template;
+import net.pterodactylus.util.template.TemplateContext;
+import net.pterodactylus.util.text.StringEscaper;
+import net.pterodactylus.util.text.TextException;
+
+/**
+ * This page lets the user search for posts and replies that contain certain
+ * words.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SearchPage extends SoneTemplatePage {
+
+       /**
+        * Creates a new search page.
+        *
+        * @param template
+        *            The template to render
+        * @param webInterface
+        *            The Sone web interface
+        */
+       public SearchPage(Template template, WebInterface webInterface) {
+               super("search.html", template, "Page.Search.Title", webInterface);
+       }
+
+       //
+       // SONETEMPLATEPAGE METHODS
+       //
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
+               super.processTemplate(request, templateContext);
+               String query = request.getHttpRequest().getParam("query").trim();
+               if (query.length() == 0) {
+                       throw new RedirectException("index.html");
+               }
+
+               List<Phrase> phrases = parseSearchPhrases(query);
+
+               Set<Sone> sones = webInterface.getCore().getSones();
+               Set<Hit<Sone>> soneHits = getHits(sones, phrases, SoneStringGenerator.COMPLETE_GENERATOR);
+
+               Set<Post> posts = new HashSet<Post>();
+               for (Sone sone : sones) {
+                       posts.addAll(sone.getPosts());
+               }
+               @SuppressWarnings("synthetic-access")
+               Set<Hit<Post>> postHits = getHits(Filters.filteredSet(posts, Post.FUTURE_POSTS_FILTER), phrases, new PostStringGenerator());
+
+               /* now filter. */
+               soneHits = Filters.filteredSet(soneHits, Hit.POSITIVE_FILTER);
+               postHits = Filters.filteredSet(postHits, Hit.POSITIVE_FILTER);
+
+               /* now sort. */
+               List<Hit<Sone>> sortedSoneHits = new ArrayList<Hit<Sone>>(soneHits);
+               Collections.sort(sortedSoneHits, Hit.DESCENDING_COMPARATOR);
+               List<Hit<Post>> sortedPostHits = new ArrayList<Hit<Post>>(postHits);
+               Collections.sort(sortedPostHits, Hit.DESCENDING_COMPARATOR);
+
+               /* extract Sones and posts. */
+               List<Sone> resultSones = Converters.convertList(sortedSoneHits, new HitConverter<Sone>());
+               List<Post> resultPosts = Converters.convertList(sortedPostHits, new HitConverter<Post>());
+
+               /* pagination. */
+               Pagination<Sone> sonePagination = new Pagination<Sone>(resultSones, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("sonePage"), 0));
+               Pagination<Post> postPagination = new Pagination<Post>(resultPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("postPage"), 0));
+
+               templateContext.set("sonePagination", sonePagination);
+               templateContext.set("soneHits", sonePagination.getItems());
+               templateContext.set("postPagination", postPagination);
+               templateContext.set("postHits", postPagination.getItems());
+       }
+
+       //
+       // PRIVATE METHODS
+       //
+
+       /**
+        * Collects hit information for the given objects. The objects are converted
+        * to a {@link String} using the given {@link StringGenerator}, and the
+        * {@link #calculateScore(List, String) calculated score} is stored together
+        * with the object in a {@link Hit}, and all resulting {@link Hit}s are then
+        * returned.
+        *
+        * @param <T>
+        *            The type of the objects
+        * @param objects
+        *            The objects to search over
+        * @param phrases
+        *            The phrases to search for
+        * @param stringGenerator
+        *            The string generator for the objects
+        * @return The hits for the given phrases
+        */
+       private <T> Set<Hit<T>> getHits(Collection<T> objects, List<Phrase> phrases, StringGenerator<T> stringGenerator) {
+               Set<Hit<T>> hits = new HashSet<Hit<T>>();
+               for (T object : objects) {
+                       String objectString = stringGenerator.generateString(object);
+                       int score = calculateScore(phrases, objectString);
+                       hits.add(new Hit<T>(object, score));
+               }
+               return hits;
+       }
+
+       /**
+        * Parses the given query into search phrases. The query is split on
+        * whitespace while allowing to group words using single or double quotes.
+        * Isolated phrases starting with a “+” are
+        * {@link Phrase.Optionality#REQUIRED}, phrases with a “-” are
+        * {@link Phrase.Optionality#FORBIDDEN}.
+        *
+        * @param query
+        *            The query to parse
+        * @return The parsed phrases
+        */
+       private List<Phrase> parseSearchPhrases(String query) {
+               List<String> parsedPhrases = null;
+               try {
+                       parsedPhrases = StringEscaper.parseLine(query);
+               } catch (TextException te1) {
+                       /* invalid query. */
+                       return Collections.emptyList();
+               }
+
+               List<Phrase> phrases = new ArrayList<Phrase>();
+               for (String phrase : parsedPhrases) {
+                       if (phrase.startsWith("+")) {
+                               phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.REQUIRED));
+                       } else if (phrase.startsWith("-")) {
+                               phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.FORBIDDEN));
+                       }
+                       phrases.add(new Phrase(phrase, Phrase.Optionality.OPTIONAL));
+               }
+               return phrases;
+       }
+
+       /**
+        * Calculates the score for the given expression when using the given
+        * phrases.
+        *
+        * @param phrases
+        *            The phrases to search for
+        * @param expression
+        *            The expression to search
+        * @return The score of the expression
+        */
+       private int calculateScore(List<Phrase> phrases, String expression) {
+               int optionalHits = 0;
+               int requiredHits = 0;
+               int forbiddenHits = 0;
+               int requiredPhrases = 0;
+               for (Phrase phrase : phrases) {
+                       String phraseString = phrase.getPhrase().toLowerCase();
+                       if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) {
+                               ++requiredPhrases;
+                       }
+                       int matches = 0;
+                       int index = 0;
+                       while (index < expression.length()) {
+                               int position = expression.toLowerCase().indexOf(phraseString, index);
+                               if (position == -1) {
+                                       break;
+                               }
+                               index = position + phraseString.length();
+                               ++matches;
+                       }
+                       if (matches == 0) {
+                               continue;
+                       }
+                       if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) {
+                               requiredHits += matches;
+                       }
+                       if (phrase.getOptionality() == Phrase.Optionality.OPTIONAL) {
+                               optionalHits += matches;
+                       }
+                       if (phrase.getOptionality() == Phrase.Optionality.FORBIDDEN) {
+                               forbiddenHits += matches;
+                       }
+               }
+               return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2);
+       }
+
+       /**
+        * Converts a given object into a {@link String}.
+        *
+        * @param <T>
+        *            The type of the objects
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       private static interface StringGenerator<T> {
+
+               /**
+                * Generates a {@link String} for the given object.
+                *
+                * @param object
+                *            The object to generate the {@link String} for
+                * @return The generated {@link String}
+                */
+               public String generateString(T object);
+
+       }
+
+       /**
+        * Generates a {@link String} from a {@link Sone}, concatenating the name of
+        * the Sone and all {@link Profile} {@link Field} values.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       private static class SoneStringGenerator implements StringGenerator<Sone> {
+
+               /** A static instance of a complete Sone string generator. */
+               public static final SoneStringGenerator COMPLETE_GENERATOR = new SoneStringGenerator(true);
+
+               /**
+                * A static instance of a Sone string generator that will only use the
+                * name of the Sone.
+                */
+               public static final SoneStringGenerator NAME_GENERATOR = new SoneStringGenerator(false);
+
+               /** Whether to generate a string from all data of a Sone. */
+               private final boolean complete;
+
+               /**
+                * Creates a new Sone string generator.
+                *
+                * @param complete
+                *            {@code true} to use the profile’s fields, {@code false} to
+                *            not to use the profile‘s fields
+                */
+               private SoneStringGenerator(boolean complete) {
+                       this.complete = complete;
+               }
+
+               /**
+                * {@inheritDoc}
+                */
+               @Override
+               public String generateString(Sone sone) {
+                       StringBuilder soneString = new StringBuilder();
+                       soneString.append(sone.getName());
+                       Profile soneProfile = sone.getProfile();
+                       if (soneProfile.getFirstName() != null) {
+                               soneString.append(' ').append(soneProfile.getFirstName());
+                       }
+                       if (soneProfile.getMiddleName() != null) {
+                               soneString.append(' ').append(soneProfile.getMiddleName());
+                       }
+                       if (soneProfile.getLastName() != null) {
+                               soneString.append(' ').append(soneProfile.getLastName());
+                       }
+                       if (complete) {
+                               for (Field field : soneProfile.getFields()) {
+                                       soneString.append(' ').append(field.getValue());
+                               }
+                       }
+                       return soneString.toString();
+               }
+
+       }
+
+       /**
+        * Generates a {@link String} from a {@link Post}, concatenating the text of
+        * the post, the text of all {@link Reply}s, and the name of all
+        * {@link Sone}s that have replied.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       private class PostStringGenerator implements StringGenerator<Post> {
+
+               /**
+                * {@inheritDoc}
+                */
+               @Override
+               public String generateString(Post post) {
+                       StringBuilder postString = new StringBuilder();
+                       postString.append(post.getText());
+                       if (post.getRecipient() != null) {
+                               postString.append(' ').append(SoneStringGenerator.NAME_GENERATOR.generateString(post.getRecipient()));
+                       }
+                       for (Reply reply : Filters.filteredList(webInterface.getCore().getReplies(post), Reply.FUTURE_REPLIES_FILTER)) {
+                               postString.append(' ').append(SoneStringGenerator.NAME_GENERATOR.generateString(reply.getSone()));
+                               postString.append(' ').append(reply.getText());
+                       }
+                       return postString.toString();
+               }
+
+       }
+
+       /**
+        * A search phrase.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       private static class Phrase {
+
+               /**
+                * The optionality of a search phrase.
+                *
+                * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’
+                *         Roden</a>
+                */
+               public enum Optionality {
+
+                       /** The phrase is optional. */
+                       OPTIONAL,
+
+                       /** The phrase is required. */
+                       REQUIRED,
+
+                       /** The phrase is forbidden. */
+                       FORBIDDEN
+
+               }
+
+               /** The phrase to search for. */
+               private final String phrase;
+
+               /** The optionality of the phrase. */
+               private final Optionality optionality;
+
+               /**
+                * Creates a new phrase.
+                *
+                * @param phrase
+                *            The phrase to search for
+                * @param optionality
+                *            The optionality of the phrase
+                */
+               public Phrase(String phrase, Optionality optionality) {
+                       this.optionality = optionality;
+                       this.phrase = phrase;
+               }
+
+               /**
+                * Returns the phrase to search for.
+                *
+                * @return The phrase to search for
+                */
+               public String getPhrase() {
+                       return phrase;
+               }
+
+               /**
+                * Returns the optionality of the phrase.
+                *
+                * @return The optionality of the phrase
+                */
+               public Optionality getOptionality() {
+                       return optionality;
+               }
+
+       }
+
+       /**
+        * A hit consists of a searched object and the score it got for the phrases
+        * of the search.
+        *
+        * @see SearchPage#calculateScore(List, String)
+        * @param <T>
+        *            The type of the searched object
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       private static class Hit<T> {
+
+               /** Filter for {@link Hit}s with a score of more than 0. */
+               public static final Filter<Hit<?>> POSITIVE_FILTER = new Filter<Hit<?>>() {
+
+                       @Override
+                       public boolean filterObject(Hit<?> hit) {
+                               return hit.getScore() > 0;
+                       }
+
+               };
+
+               /** Comparator that sorts {@link Hit}s descending by score. */
+               public static final Comparator<Hit<?>> DESCENDING_COMPARATOR = new Comparator<Hit<?>>() {
+
+                       @Override
+                       public int compare(Hit<?> leftHit, Hit<?> rightHit) {
+                               return rightHit.getScore() - leftHit.getScore();
+                       }
+
+               };
+
+               /** The object that was searched. */
+               private final T object;
+
+               /** The score of the object. */
+               private final int score;
+
+               /**
+                * Creates a new hit.
+                *
+                * @param object
+                *            The object that was searched
+                * @param score
+                *            The score of the object
+                */
+               public Hit(T object, int score) {
+                       this.object = object;
+                       this.score = score;
+               }
+
+               /**
+                * Returns the object that was searched.
+                *
+                * @return The object that was searched
+                */
+               public T getObject() {
+                       return object;
+               }
+
+               /**
+                * Returns the score of the object.
+                *
+                * @return The score of the object
+                */
+               public int getScore() {
+                       return score;
+               }
+
+       }
+
+       /**
+        * Extracts the object from a {@link Hit}.
+        *
+        * @param <T>
+        *            The type of the object to extract
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       public static class HitConverter<T> implements Converter<Hit<T>, T> {
+
+               /**
+                * {@inheritDoc}
+                */
+               @Override
+               public T convert(Hit<T> input) {
+                       return input.getObject();
+               }
+
+       }
+
+}
index a04873d..d6c41c1 100644 (file)
@@ -21,11 +21,15 @@ import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.List;
+import java.util.Map;
 
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.main.SonePlugin;
 import net.pterodactylus.sone.web.page.Page;
-import net.pterodactylus.sone.web.page.TemplatePage;
+import net.pterodactylus.sone.web.page.FreenetTemplatePage;
+import net.pterodactylus.util.collection.ListBuilder;
+import net.pterodactylus.util.collection.MapBuilder;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import freenet.clients.http.SessionManager.Session;
@@ -37,11 +41,14 @@ import freenet.support.api.HTTPRequest;
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
-public class SoneTemplatePage extends TemplatePage {
+public class SoneTemplatePage extends FreenetTemplatePage {
 
        /** The Sone core. */
        protected final WebInterface webInterface;
 
+       /** The page title l10n key. */
+       private final String pageTitleKey;
+
        /** Whether to require a login. */
        private final boolean requireLogin;
 
@@ -53,6 +60,21 @@ public class SoneTemplatePage extends TemplatePage {
         *            The path of the page
         * @param template
         *            The template to render
+        * @param webInterface
+        *            The Sone web interface
+        */
+       public SoneTemplatePage(String path, Template template, WebInterface webInterface) {
+               this(path, template, null, webInterface, false);
+       }
+
+       /**
+        * Creates a new template page for Freetalk that does not require the user
+        * to be logged in.
+        *
+        * @param path
+        *            The path of the page
+        * @param template
+        *            The template to render
         * @param pageTitleKey
         *            The l10n key of the page title
         * @param webInterface
@@ -69,6 +91,22 @@ public class SoneTemplatePage extends TemplatePage {
         *            The path of the page
         * @param template
         *            The template to render
+        * @param webInterface
+        *            The Sone web interface
+        * @param requireLogin
+        *            Whether this page requires a login
+        */
+       public SoneTemplatePage(String path, Template template, WebInterface webInterface, boolean requireLogin) {
+               this(path, template, null, webInterface, requireLogin);
+       }
+
+       /**
+        * Creates a new template page for Freetalk.
+        *
+        * @param path
+        *            The path of the page
+        * @param template
+        *            The template to render
         * @param pageTitleKey
         *            The l10n key of the page title
         * @param webInterface
@@ -77,7 +115,8 @@ public class SoneTemplatePage extends TemplatePage {
         *            Whether this page requires a login
         */
        public SoneTemplatePage(String path, Template template, String pageTitleKey, WebInterface webInterface, boolean requireLogin) {
-               super(path, webInterface.getTemplateContextFactory(), template, webInterface.getL10n(), pageTitleKey, "noPermission.html");
+               super(path, webInterface.getTemplateContextFactory(), template, "noPermission.html");
+               this.pageTitleKey = pageTitleKey;
                this.webInterface = webInterface;
                this.requireLogin = requireLogin;
                template.getInitialContext().set("webInterface", webInterface);
@@ -163,6 +202,25 @@ public class SoneTemplatePage extends TemplatePage {
         * {@inheritDoc}
         */
        @Override
+       protected String getPageTitle(Request request) {
+               if (pageTitleKey != null) {
+                       return webInterface.getL10n().getString(pageTitleKey);
+               }
+               return "";
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       protected List<Map<String, String>> getAdditionalLinkNodes(Request request) {
+               return new ListBuilder<Map<String, String>>().add(new MapBuilder<String, String>().put("rel", "search").put("type", "application/opensearchdescription+xml").put("title", "Sone").put("href", "http://" + request.getHttpRequest().getHeader("host") + "/Sone/OpenSearch.xml").get()).get();
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
        protected Collection<String> getStyleSheets() {
                return Arrays.asList("css/sone.css");
        }
index 967b9c5..b62e19c 100644 (file)
@@ -18,7 +18,7 @@
 package net.pterodactylus.sone.web;
 
 import net.pterodactylus.sone.data.Post;
-import net.pterodactylus.sone.data.Reply;
+import net.pterodactylus.sone.template.SoneAccessor;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 
@@ -49,28 +49,29 @@ public class ViewPostPage extends SoneTemplatePage {
         * {@inheritDoc}
         */
        @Override
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
-               super.processTemplate(request, templateContext);
+       protected String getPageTitle(Request request) {
                String postId = request.getHttpRequest().getParam("post");
-               boolean raw = request.getHttpRequest().getParam("raw").equals("true");
-               Post post = webInterface.getCore().getPost(postId);
-               templateContext.set("post", post);
-               templateContext.set("raw", raw);
+               Post post = webInterface.getCore().getPost(postId, false);
+               String title = "";
+               if ((post != null) && (post.getSone() != null)) {
+                       title = post.getText().substring(0, Math.min(20, post.getText().length())) + "…";
+                       title += " - " + SoneAccessor.getNiceName(post.getSone()) + " - ";
+               }
+               title += webInterface.getL10n().getString("Page.ViewPost.Title");
+               return title;
        }
 
        /**
         * {@inheritDoc}
         */
        @Override
-       protected void postProcess(Request request, TemplateContext templateContext) {
-               Post post = (Post) templateContext.get("post");
-               if (post == null) {
-                       return;
-               }
-               webInterface.getCore().markPostKnown(post);
-               for (Reply reply : webInterface.getCore().getReplies(post)) {
-                       webInterface.getCore().markReplyKnown(reply);
-               }
+       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
+               super.processTemplate(request, templateContext);
+               String postId = request.getHttpRequest().getParam("post");
+               boolean raw = request.getHttpRequest().getParam("raw").equals("true");
+               Post post = webInterface.getCore().getPost(postId);
+               templateContext.set("post", post);
+               templateContext.set("raw", raw);
        }
 
 }
index 0792a6f..14c4f94 100644 (file)
 
 package net.pterodactylus.sone.web;
 
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.data.Reply;
 import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.template.SoneAccessor;
+import net.pterodactylus.util.collection.Pagination;
+import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 
@@ -41,7 +50,7 @@ public class ViewSonePage extends SoneTemplatePage {
         *            The Sone web interface
         */
        public ViewSonePage(Template template, WebInterface webInterface) {
-               super("viewSone.html", template, "Page.ViewSone.Title", webInterface, false);
+               super("viewSone.html", template, webInterface, false);
        }
 
        //
@@ -52,30 +61,53 @@ public class ViewSonePage extends SoneTemplatePage {
         * {@inheritDoc}
         */
        @Override
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
-               super.processTemplate(request, templateContext);
+       protected String getPageTitle(Request request) {
                String soneId = request.getHttpRequest().getParam("sone");
                Sone sone = webInterface.getCore().getSone(soneId, false);
-               templateContext.set("sone", sone);
+               if ((sone != null) && (sone.getTime() > 0)) {
+                       String soneName = SoneAccessor.getNiceName(sone);
+                       return soneName + " - " + webInterface.getL10n().getString("Page.ViewSone.Title");
+               }
+               return webInterface.getL10n().getString("Page.ViewSone.Page.TitleWithoutSone");
        }
 
        /**
         * {@inheritDoc}
         */
        @Override
-       protected void postProcess(Request request, TemplateContext templateContext) {
-               Sone sone = (Sone) templateContext.get("sone");
-               if (sone == null) {
-                       return;
-               }
-               webInterface.getCore().markSoneKnown(sone);
-               List<Post> posts = sone.getPosts();
-               for (Post post : posts) {
-                       webInterface.getCore().markPostKnown(post);
-                       for (Reply reply : webInterface.getCore().getReplies(post)) {
-                               webInterface.getCore().markReplyKnown(reply);
+       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
+               super.processTemplate(request, templateContext);
+               String soneId = request.getHttpRequest().getParam("sone");
+               Sone sone = webInterface.getCore().getSone(soneId, false);
+               templateContext.set("sone", sone);
+               List<Post> sonePosts = sone.getPosts();
+               sonePosts.addAll(webInterface.getCore().getDirectedPosts(sone));
+               Collections.sort(sonePosts, Post.TIME_COMPARATOR);
+               Pagination<Post> postPagination = new Pagination<Post>(sonePosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("postPage"), 0));
+               templateContext.set("postPagination", postPagination);
+               templateContext.set("posts", postPagination.getItems());
+               Set<Reply> replies = sone.getReplies();
+               final Map<Post, List<Reply>> repliedPosts = new HashMap<Post, List<Reply>>();
+               for (Reply reply : replies) {
+                       Post post = reply.getPost();
+                       if (repliedPosts.containsKey(post) || sone.equals(post.getSone()) || (sone.equals(post.getRecipient()))) {
+                               continue;
                        }
+                       repliedPosts.put(post, webInterface.getCore().getReplies(post));
                }
+               List<Post> posts = new ArrayList<Post>(repliedPosts.keySet());
+               Collections.sort(posts, new Comparator<Post>() {
+
+                       @Override
+                       public int compare(Post leftPost, Post rightPost) {
+                               return (int) Math.min(Integer.MAX_VALUE, Math.max(Integer.MIN_VALUE, repliedPosts.get(rightPost).get(0).getTime() - repliedPosts.get(leftPost).get(0).getTime()));
+                       }
+
+               });
+
+               Pagination<Post> repliedPostPagination = new Pagination<Post>(posts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("repliedPostPage"), 0));
+               templateContext.set("repliedPostPagination", repliedPostPagination);
+               templateContext.set("repliedPosts", repliedPostPagination.getItems());
        }
 
 }
index 376ff09..f5b111f 100644 (file)
@@ -49,7 +49,7 @@ import net.pterodactylus.sone.notify.ListNotification;
 import net.pterodactylus.sone.template.AlbumAccessor;
 import net.pterodactylus.sone.template.CollectionAccessor;
 import net.pterodactylus.sone.template.CssClassNameFilter;
-import net.pterodactylus.sone.template.GetPagePlugin;
+import net.pterodactylus.sone.template.HttpRequestAccessor;
 import net.pterodactylus.sone.template.IdentityAccessor;
 import net.pterodactylus.sone.template.ImageLinkFilter;
 import net.pterodactylus.sone.template.JavascriptFilter;
@@ -57,6 +57,7 @@ import net.pterodactylus.sone.template.NotificationManagerAccessor;
 import net.pterodactylus.sone.template.ParserFilter;
 import net.pterodactylus.sone.template.PostAccessor;
 import net.pterodactylus.sone.template.ReplyAccessor;
+import net.pterodactylus.sone.template.ReplyGroupFilter;
 import net.pterodactylus.sone.template.RequestChangeFilter;
 import net.pterodactylus.sone.template.SoneAccessor;
 import net.pterodactylus.sone.template.SubstringFilter;
@@ -76,6 +77,7 @@ import net.pterodactylus.sone.web.ajax.GetLikesAjaxPage;
 import net.pterodactylus.sone.web.ajax.GetPostAjaxPage;
 import net.pterodactylus.sone.web.ajax.GetReplyAjaxPage;
 import net.pterodactylus.sone.web.ajax.GetStatusAjaxPage;
+import net.pterodactylus.sone.web.ajax.GetTimesAjaxPage;
 import net.pterodactylus.sone.web.ajax.GetTranslationPage;
 import net.pterodactylus.sone.web.ajax.LikeAjaxPage;
 import net.pterodactylus.sone.web.ajax.LockSoneAjaxPage;
@@ -89,7 +91,9 @@ 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.RedirectPage;
 import net.pterodactylus.sone.web.page.StaticPage;
+import net.pterodactylus.sone.web.page.TemplatePage;
 import net.pterodactylus.util.cache.Cache;
 import net.pterodactylus.util.cache.CacheException;
 import net.pterodactylus.util.cache.CacheItem;
@@ -105,7 +109,6 @@ import net.pterodactylus.util.template.DateFilter;
 import net.pterodactylus.util.template.FormatFilter;
 import net.pterodactylus.util.template.HtmlFilter;
 import net.pterodactylus.util.template.MatchFilter;
-import net.pterodactylus.util.template.PaginationPlugin;
 import net.pterodactylus.util.template.Provider;
 import net.pterodactylus.util.template.ReflectionAccessor;
 import net.pterodactylus.util.template.ReplaceFilter;
@@ -123,6 +126,7 @@ import freenet.clients.http.SessionManager.Session;
 import freenet.clients.http.ToadletContainer;
 import freenet.clients.http.ToadletContext;
 import freenet.l10n.BaseL10n;
+import freenet.support.api.HTTPRequest;
 
 /**
  * Bundles functionality that a web interface of a Freenet plugin needs, e.g.
@@ -204,6 +208,7 @@ public class WebInterface implements CoreListener {
                templateContextFactory.addAccessor(Identity.class, new IdentityAccessor(getCore()));
                templateContextFactory.addAccessor(NotificationManager.class, new NotificationManagerAccessor());
                templateContextFactory.addAccessor(Trust.class, new TrustAccessor());
+               templateContextFactory.addAccessor(HTTPRequest.class, new HttpRequestAccessor());
                templateContextFactory.addFilter("date", new DateFilter());
                templateContextFactory.addFilter("html", new HtmlFilter());
                templateContextFactory.addFilter("replace", new ReplaceFilter());
@@ -220,19 +225,20 @@ public class WebInterface implements CoreListener {
                templateContextFactory.addFilter("format", new FormatFilter());
                templateContextFactory.addFilter("sort", new CollectionSortFilter());
                templateContextFactory.addFilter("image-link", new ImageLinkFilter(templateContextFactory));
+               templateContextFactory.addFilter("replyGroup", new ReplyGroupFilter());
                templateContextFactory.addProvider(Provider.TEMPLATE_CONTEXT_PROVIDER);
                templateContextFactory.addProvider(new ClassPathTemplateProvider());
                templateContextFactory.addTemplateObject("formPassword", formPassword);
 
                /* create notifications. */
                Template newSoneNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newSoneNotification.html"));
-               newSoneNotification = new ListNotification<Sone>("new-sone-notification", "sones", newSoneNotificationTemplate);
+               newSoneNotification = new ListNotification<Sone>("new-sone-notification", "sones", newSoneNotificationTemplate, false);
 
                Template newPostNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newPostNotification.html"));
-               newPostNotification = new ListNotification<Post>("new-post-notification", "posts", newPostNotificationTemplate);
+               newPostNotification = new ListNotification<Post>("new-post-notification", "posts", newPostNotificationTemplate, false);
 
                Template newReplyNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newReplyNotification.html"));
-               newReplyNotification = new ListNotification<Reply>("new-replies-notification", "replies", newReplyNotificationTemplate);
+               newReplyNotification = new ListNotification<Reply>("new-replies-notification", "replies", newReplyNotificationTemplate, false);
 
                Template rescuingSonesTemplate = TemplateParser.parse(createReader("/templates/notify/rescuingSonesNotification.html"));
                rescuingSonesNotification = new ListNotification<Sone>("sones-being-rescued-notification", "sones", rescuingSonesTemplate);
@@ -545,6 +551,7 @@ public class WebInterface implements CoreListener {
                Template createPostTemplate = TemplateParser.parse(createReader("/templates/createPost.html"));
                Template createReplyTemplate = TemplateParser.parse(createReader("/templates/createReply.html"));
                Template bookmarksTemplate = TemplateParser.parse(createReader("/templates/bookmarks.html"));
+               Template searchTemplate = TemplateParser.parse(createReader("/templates/search.html"));
                Template editProfileTemplate = TemplateParser.parse(createReader("/templates/editProfile.html"));
                Template editProfileFieldTemplate = TemplateParser.parse(createReader("/templates/editProfileField.html"));
                Template deleteProfileFieldTemplate = TemplateParser.parse(createReader("/templates/deleteProfileField.html"));
@@ -563,8 +570,10 @@ public class WebInterface implements CoreListener {
                Template invalidTemplate = TemplateParser.parse(createReader("/templates/invalid.html"));
                Template postTemplate = TemplateParser.parse(createReader("/templates/include/viewPost.html"));
                Template replyTemplate = TemplateParser.parse(createReader("/templates/include/viewReply.html"));
+               Template openSearchTemplate = TemplateParser.parse(createReader("/templates/xml/OpenSearch.xml"));
 
                PageToadletFactory pageToadletFactory = new PageToadletFactory(sonePlugin.pluginRespirator().getHLSimpleClient(), "/Sone/");
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new RedirectPage("", "index.html")));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new IndexPage(indexTemplate, this), "Index"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new CreateSonePage(createSoneTemplate, this), "CreateSone"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new KnownSonesPage(knownSonesTemplate, this), "KnownSones"));
@@ -597,6 +606,7 @@ public class WebInterface implements CoreListener {
                pageToadlets.add(pageToadletFactory.createPageToadlet(new BookmarkPage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new UnbookmarkPage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new BookmarksPage(bookmarksTemplate, this), "Bookmarks"));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new SearchPage(searchTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteSonePage(deleteSoneTemplate, this), "DeleteSone"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new LoginPage(loginTemplate, this), "Login"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new LogoutPage(emptyTemplate, this), "Logout"));
@@ -617,6 +627,7 @@ public class WebInterface implements CoreListener {
                pageToadlets.add(pageToadletFactory.createPageToadlet(new CreateReplyAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetReplyAjaxPage(this, replyTemplate)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetPostAjaxPage(this, postTemplate)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new GetTimesAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new MarkAsKnownAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DeletePostAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteReplyAjaxPage(this)));
index fd9ae76..426c08f 100644 (file)
@@ -31,8 +31,11 @@ import java.util.Set;
 import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.data.Reply;
 import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.notify.ListNotificationFilters;
 import net.pterodactylus.sone.template.SoneAccessor;
 import net.pterodactylus.sone.web.WebInterface;
+import net.pterodactylus.util.filter.Filter;
+import net.pterodactylus.util.filter.Filters;
 import net.pterodactylus.util.json.JsonArray;
 import net.pterodactylus.util.json.JsonObject;
 import net.pterodactylus.util.notify.Notification;
@@ -65,6 +68,7 @@ public class GetStatusAjaxPage extends JsonPage {
         */
        @Override
        protected JsonObject createJsonObject(Request request) {
+               final Sone currentSone = getCurrentSone(request.getToadletContext(), false);
                /* load Sones. */
                boolean loadAllSones = Boolean.parseBoolean(request.getHttpRequest().getParam("loadAllSones", "true"));
                Set<Sone> sones = new HashSet<Sone>(Collections.singleton(getCurrentSone(request.getToadletContext(), false)));
@@ -80,19 +84,24 @@ public class GetStatusAjaxPage extends JsonPage {
                        jsonSones.add(jsonSone);
                }
                /* load notifications. */
-               List<Notification> notifications = new ArrayList<Notification>(webInterface.getNotifications().getChangedNotifications());
-               Set<Notification> removedNotifications = webInterface.getNotifications().getRemovedNotifications();
+               List<Notification> notifications = ListNotificationFilters.filterNotifications(new ArrayList<Notification>(webInterface.getNotifications().getNotifications()), currentSone);
                Collections.sort(notifications, Notification.LAST_UPDATED_TIME_SORTER);
                JsonArray jsonNotifications = new JsonArray();
                for (Notification notification : notifications) {
                        jsonNotifications.add(createJsonNotification(notification));
                }
-               JsonArray jsonRemovedNotifications = new JsonArray();
-               for (Notification notification : removedNotifications) {
-                       jsonRemovedNotifications.add(createJsonNotification(notification));
-               }
                /* load new posts. */
                Set<Post> newPosts = webInterface.getNewPosts();
+               if (currentSone != null) {
+                       newPosts = Filters.filteredSet(newPosts, new Filter<Post>() {
+
+                               @Override
+                               public boolean filterObject(Post post) {
+                                       return currentSone.hasFriend(post.getSone().getId()) || currentSone.equals(post.getSone()) || currentSone.equals(post.getRecipient());
+                               }
+
+                       });
+               }
                JsonArray jsonPosts = new JsonArray();
                for (Post post : newPosts) {
                        JsonObject jsonPost = new JsonObject();
@@ -104,6 +113,16 @@ public class GetStatusAjaxPage extends JsonPage {
                }
                /* load new replies. */
                Set<Reply> newReplies = webInterface.getNewReplies();
+               if (currentSone != null) {
+                       newReplies = Filters.filteredSet(newReplies, new Filter<Reply>() {
+
+                               @Override
+                               public boolean filterObject(Reply reply) {
+                                       return currentSone.hasFriend(reply.getPost().getSone().getId()) || currentSone.equals(reply.getPost().getSone()) || currentSone.equals(reply.getPost().getRecipient());
+                               }
+
+                       });
+               }
                JsonArray jsonReplies = new JsonArray();
                for (Reply reply : newReplies) {
                        JsonObject jsonReply = new JsonObject();
@@ -113,7 +132,7 @@ public class GetStatusAjaxPage extends JsonPage {
                        jsonReply.put("postSone", reply.getPost().getSone().getId());
                        jsonReplies.add(jsonReply);
                }
-               return createSuccessJsonObject().put("sones", jsonSones).put("notifications", jsonNotifications).put("removedNotifications", jsonRemovedNotifications).put("newPosts", jsonPosts).put("newReplies", jsonReplies);
+               return createSuccessJsonObject().put("sones", jsonSones).put("notifications", jsonNotifications).put("newPosts", jsonPosts).put("newReplies", jsonReplies);
        }
 
        /**
diff --git a/src/main/java/net/pterodactylus/sone/web/ajax/GetTimesAjaxPage.java b/src/main/java/net/pterodactylus/sone/web/ajax/GetTimesAjaxPage.java
new file mode 100644 (file)
index 0000000..b17e4a2
--- /dev/null
@@ -0,0 +1,244 @@
+/*
+ * Sone - GetTimesAjaxPage.java - Copyright © 2010–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.ajax;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.Reply;
+import net.pterodactylus.sone.web.WebInterface;
+import net.pterodactylus.util.json.JsonObject;
+import net.pterodactylus.util.number.Digits;
+
+/**
+ * Ajax page that returns a formatted, relative timestamp for replies or posts.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class GetTimesAjaxPage extends JsonPage {
+
+       /** Formatter for tooltips. */
+       private static final DateFormat dateFormat = new SimpleDateFormat("MMM d, yyyy, HH:mm:ss");
+
+       /**
+        * Creates a new get times AJAX page.
+        *
+        * @param webInterface
+        *            The Sone web interface
+        */
+       public GetTimesAjaxPage(WebInterface webInterface) {
+               super("getTimes.ajax", webInterface);
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       protected JsonObject createJsonObject(Request request) {
+               long now = System.currentTimeMillis();
+               String allIds = request.getHttpRequest().getParam("posts");
+               JsonObject postTimes = new JsonObject();
+               if (allIds.length() > 0) {
+                       String[] ids = allIds.split(",");
+                       for (String id : ids) {
+                               Post post = webInterface.getCore().getPost(id, false);
+                               if (post == null) {
+                                       continue;
+                               }
+                               long age = now - post.getTime();
+                               JsonObject postTime = new JsonObject();
+                               Time time = getTime(age);
+                               postTime.put("timeText", time.getText());
+                               postTime.put("refreshTime", time.getRefresh() / Time.SECOND);
+                               postTime.put("tooltip", dateFormat.format(new Date(post.getTime())));
+                               postTimes.put(id, postTime);
+                       }
+               }
+               JsonObject replyTimes = new JsonObject();
+               allIds = request.getHttpRequest().getParam("replies");
+               if (allIds.length() > 0) {
+                       String[] ids = allIds.split(",");
+                       for (String id : ids) {
+                               Reply reply = webInterface.getCore().getReply(id, false);
+                               if (reply == null) {
+                                       continue;
+                               }
+                               long age = now - reply.getTime();
+                               JsonObject replyTime = new JsonObject();
+                               Time time = getTime(age);
+                               replyTime.put("timeText", time.getText());
+                               replyTime.put("refreshTime", time.getRefresh() / Time.SECOND);
+                               replyTime.put("tooltip", dateFormat.format(new Date(reply.getTime())));
+                               replyTimes.put(id, replyTime);
+                       }
+               }
+               return createSuccessJsonObject().put("postTimes", postTimes).put("replyTimes", replyTimes);
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       protected boolean needsFormPassword() {
+               return false;
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       protected boolean requiresLogin() {
+               return false;
+       }
+
+       //
+       // PRIVATE METHODS
+       //
+
+       /**
+        * Returns the formatted relative time for a given age.
+        *
+        * @param age
+        *            The age to format (in milliseconds)
+        * @return The formatted age
+        */
+       private Time getTime(long age) {
+               String text;
+               long refresh;
+               if (age < 0) {
+                       text = webInterface.getL10n().getDefaultString("View.Time.InTheFuture");
+                       refresh = 5 * Time.MINUTE;
+               } else if (age < 20 * Time.SECOND) {
+                       text = webInterface.getL10n().getDefaultString("View.Time.AFewSecondsAgo");
+                       refresh = 10 * Time.SECOND;
+               } else if (age < 45 * Time.SECOND) {
+                       text = webInterface.getL10n().getString("View.Time.HalfAMinuteAgo");
+                       refresh = 20 * Time.SECOND;
+               } else if (age < 90 * Time.SECOND) {
+                       text = webInterface.getL10n().getString("View.Time.AMinuteAgo");
+                       refresh = Time.MINUTE;
+               } else if (age < 30 * Time.MINUTE) {
+                       text = webInterface.getL10n().getString("View.Time.XMinutesAgo", "min", String.valueOf((int) Digits.round(age / Time.MINUTE, 1)));
+                       refresh = 1 * Time.MINUTE;
+               } else if (age < 45 * Time.MINUTE) {
+                       text = webInterface.getL10n().getString("View.Time.HalfAnHourAgo");
+                       refresh = 10 * Time.MINUTE;
+               } else if (age < 90 * Time.MINUTE) {
+                       text = webInterface.getL10n().getString("View.Time.AnHourAgo");
+                       refresh = Time.HOUR;
+               } else if (age < 21 * Time.HOUR) {
+                       text = webInterface.getL10n().getString("View.Time.XHoursAgo", "hour", String.valueOf((int) Digits.round(age / Time.HOUR, 1)));
+                       refresh = Time.HOUR;
+               } else if (age < 42 * Time.HOUR) {
+                       text = webInterface.getL10n().getString("View.Time.ADayAgo");
+                       refresh = Time.DAY;
+               } else if (age < 6 * Time.DAY) {
+                       text = webInterface.getL10n().getString("View.Time.XDaysAgo", "day", String.valueOf((int) Digits.round(age / Time.DAY, 1)));
+                       refresh = Time.DAY;
+               } else if (age < 11 * Time.DAY) {
+                       text = webInterface.getL10n().getString("View.Time.AWeekAgo");
+                       refresh = Time.DAY;
+               } else if (age < 4 * Time.WEEK) {
+                       text = webInterface.getL10n().getString("View.Time.XWeeksAgo", "week", String.valueOf((int) Digits.round(age / Time.WEEK, 1)));
+                       refresh = Time.DAY;
+               } else if (age < 6 * Time.WEEK) {
+                       text = webInterface.getL10n().getString("View.Time.AMonthAgo");
+                       refresh = Time.DAY;
+               } else if (age < 11 * Time.MONTH) {
+                       text = webInterface.getL10n().getString("View.Time.XMonthsAgo", "month", String.valueOf((int) Digits.round(age / Time.MONTH, 1)));
+                       refresh = Time.DAY;
+               } else if (age < 18 * Time.MONTH) {
+                       text = webInterface.getL10n().getString("View.Time.AYearAgo");
+                       refresh = Time.WEEK;
+               } else {
+                       text = webInterface.getL10n().getString("View.Time.XYearsAgo", "year", String.valueOf((int) Digits.round(age / Time.YEAR, 1)));
+                       refresh = Time.WEEK;
+               }
+               return new Time(text, refresh);
+       }
+
+       /**
+        * Container for a formatted time.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       private static class Time {
+
+               /** Number of milliseconds in a second. */
+               private static final long SECOND = 1000;
+
+               /** Number of milliseconds in a minute. */
+               private static final long MINUTE = 60 * SECOND;
+
+               /** Number of milliseconds in an hour. */
+               private static final long HOUR = 60 * MINUTE;
+
+               /** Number of milliseconds in a day. */
+               private static final long DAY = 24 * HOUR;
+
+               /** Number of milliseconds in a week. */
+               private static final long WEEK = 7 * DAY;
+
+               /** Number of milliseconds in a 30-day month. */
+               private static final long MONTH = 30 * DAY;
+
+               /** Number of milliseconds in a year. */
+               private static final long YEAR = 365 * DAY;
+
+               /** The formatted time. */
+               private final String text;
+
+               /** The time after which to refresh the time. */
+               private final long refresh;
+
+               /**
+                * Creates a new formatted time container.
+                *
+                * @param text
+                *            The formatted time
+                * @param refresh
+                *            The time after which to refresh the time (in milliseconds)
+                */
+               public Time(String text, long refresh) {
+                       this.text = text;
+                       this.refresh = refresh;
+               }
+
+               /**
+                * Returns the formatted time.
+                *
+                * @return The formatted time
+                */
+               public String getText() {
+                       return text;
+               }
+
+               /**
+                * Returns the time after which to refresh the time.
+                *
+                * @return The time after which to refresh the time (in milliseconds)
+                */
+               public long getRefresh() {
+                       return refresh;
+               }
+
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/web/page/FreenetTemplatePage.java b/src/main/java/net/pterodactylus/sone/web/page/FreenetTemplatePage.java
new file mode 100644 (file)
index 0000000..0668154
--- /dev/null
@@ -0,0 +1,276 @@
+/*
+ * shortener - TemplatePage.java - Copyright © 2010 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.web.page;
+
+import java.io.StringWriter;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import net.pterodactylus.sone.web.page.Page.Request.Method;
+import net.pterodactylus.util.logging.Logging;
+import net.pterodactylus.util.template.Template;
+import net.pterodactylus.util.template.TemplateContext;
+import net.pterodactylus.util.template.TemplateContextFactory;
+import freenet.clients.http.LinkEnabledCallback;
+import freenet.clients.http.PageMaker;
+import freenet.clients.http.PageNode;
+import freenet.clients.http.ToadletContext;
+import freenet.support.HTMLNode;
+
+/**
+ * Base class for all {@link Page}s that are rendered with {@link Template}s and
+ * fit into Freenet’s web interface.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class FreenetTemplatePage implements Page, LinkEnabledCallback {
+
+       /** The logger. */
+       private static final Logger logger = Logging.getLogger(FreenetTemplatePage.class);
+
+       /** The path of the page. */
+       private final String path;
+
+       /** The template context factory. */
+       private final TemplateContextFactory templateContextFactory;
+
+       /** The template to render. */
+       private final Template template;
+
+       /** Where to redirect for invalid form passwords. */
+       private final String invalidFormPasswordRedirectTarget;
+
+       /**
+        * Creates a new template page.
+        *
+        * @param path
+        *            The path of the page
+        * @param templateContextFactory
+        *            The template context factory
+        * @param template
+        *            The template to render
+        * @param invalidFormPasswordRedirectTarget
+        *            The target to redirect to if a POST request does not contain
+        *            the correct form password
+        */
+       public FreenetTemplatePage(String path, TemplateContextFactory templateContextFactory, Template template, String invalidFormPasswordRedirectTarget) {
+               this.path = path;
+               this.templateContextFactory = templateContextFactory;
+               this.template = template;
+               this.invalidFormPasswordRedirectTarget = invalidFormPasswordRedirectTarget;
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public String getPath() {
+               return path;
+       }
+
+       /**
+        * Returns the title of the page.
+        *
+        * @param request
+        *            The request to serve
+        * @return The title of the page
+        */
+       protected String getPageTitle(Request request) {
+               return null;
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public Response handleRequest(Request request) {
+               String redirectTarget = getRedirectTarget(request);
+               if (redirectTarget != null) {
+                       return new RedirectResponse(redirectTarget);
+               }
+
+               ToadletContext toadletContext = request.getToadletContext();
+               if (request.getMethod() == Method.POST) {
+                       /* require form password. */
+                       String formPassword = request.getHttpRequest().getPartAsStringFailsafe("formPassword", 32);
+                       if (!formPassword.equals(toadletContext.getContainer().getFormPassword())) {
+                               return new RedirectResponse(invalidFormPasswordRedirectTarget);
+                       }
+               }
+               PageMaker pageMaker = toadletContext.getPageMaker();
+               PageNode pageNode = pageMaker.getPageNode(getPageTitle(request), toadletContext);
+               for (String styleSheet : getStyleSheets()) {
+                       pageNode.addCustomStyleSheet(styleSheet);
+               }
+               for (Map<String, String> linkNodeParameters : getAdditionalLinkNodes(request)) {
+                       HTMLNode linkNode = pageNode.headNode.addChild("link");
+                       for (Entry<String, String> parameter : linkNodeParameters.entrySet()) {
+                               linkNode.addAttribute(parameter.getKey(), parameter.getValue());
+                       }
+               }
+               String shortcutIcon = getShortcutIcon();
+               if (shortcutIcon != null) {
+                       pageNode.addForwardLink("icon", shortcutIcon);
+               }
+
+               TemplateContext templateContext = templateContextFactory.createTemplateContext();
+               templateContext.mergeContext(template.getInitialContext());
+               try {
+                       long start = System.nanoTime();
+                       processTemplate(request, templateContext);
+                       long finish = System.nanoTime();
+                       logger.log(Level.FINEST, "Template was rendered in " + ((finish - start) / 1000) / 1000.0 + "ms.");
+               } catch (RedirectException re1) {
+                       return new RedirectResponse(re1.getTarget());
+               }
+
+               StringWriter stringWriter = new StringWriter();
+               template.render(templateContext, stringWriter);
+               pageNode.content.addChild("%", stringWriter.toString());
+
+               postProcess(request, templateContext);
+
+               return new Response(200, "OK", "text/html", pageNode.outer.generate());
+       }
+
+       /**
+        * Can be overridden to return a custom set of style sheets that are to be
+        * included in the page’s header.
+        *
+        * @return Additional style sheets to load
+        */
+       protected Collection<String> getStyleSheets() {
+               return Collections.emptySet();
+       }
+
+       /**
+        * Returns the name of the shortcut icon to include in the page’s header.
+        *
+        * @return The URL of the shortcut icon, or {@code null} for no icon
+        */
+       protected String getShortcutIcon() {
+               return null;
+       }
+
+       /**
+        * Can be overridden when extending classes need to set variables in the
+        * template before it is rendered.
+        *
+        * @param request
+        *            The request that is rendered
+        * @param templateContext
+        *            The template context to set variables in
+        * @throws RedirectException
+        *             if the processing page wants to redirect after processing
+        */
+       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
+               /* do nothing. */
+       }
+
+       /**
+        * This method will be called after
+        * {@link #processTemplate(net.pterodactylus.sone.web.page.Page.Request, TemplateContext)}
+        * has processed the template and the template was rendered. This method
+        * will not be called if
+        * {@link #processTemplate(net.pterodactylus.sone.web.page.Page.Request, TemplateContext)}
+        * throws a {@link RedirectException}!
+        *
+        * @param request
+        *            The request being processed
+        * @param templateContext
+        *            The template context that supplied the rendered data
+        */
+       protected void postProcess(Request request, TemplateContext templateContext) {
+               /* do nothing. */
+       }
+
+       /**
+        * Can be overridden to redirect the user to a different page, in case a log
+        * in is required, or something else is wrong.
+        *
+        * @param request
+        *            The request that is processed
+        * @return The URL to redirect to, or {@code null} to not redirect
+        */
+       protected String getRedirectTarget(Page.Request request) {
+               return null;
+       }
+
+       /**
+        * Returns additional &lt;link&gt; nodes for the HTML’s &lt;head&gt; node.
+        *
+        * @param request
+        *            The request for which to return the link nodes
+        * @return All link nodes that should be added to the HTML head
+        */
+       protected List<Map<String, String>> getAdditionalLinkNodes(Request request) {
+               return Collections.emptyList();
+       }
+
+       //
+       // INTERFACE LinkEnabledCallback
+       //
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public boolean isEnabled(ToadletContext toadletContext) {
+               return true;
+       }
+
+       /**
+        * Exception that can be thrown to signal that a subclassed {@link Page}
+        * wants to redirect the user during the
+        * {@link FreenetTemplatePage#processTemplate(net.pterodactylus.sone.web.page.Page.Request, TemplateContext)}
+        * method call.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       public static class RedirectException extends Exception {
+
+               /** The target to redirect to. */
+               private final String target;
+
+               /**
+                * Creates a new redirect exception.
+                *
+                * @param target
+                *            The target of the redirect
+                */
+               public RedirectException(String target) {
+                       this.target = target;
+               }
+
+               /**
+                * Returns the target to redirect to.
+                *
+                * @return The target to redirect to
+                */
+               public String getTarget() {
+                       return target;
+               }
+
+       }
+
+}
index 4334a7f..f7f59a6 100644 (file)
@@ -53,6 +53,7 @@ public class PageToadlet extends Toadlet implements LinkEnabledCallback {
         * Creates a new toadlet that hands off processing to a {@link Page}.
         *
         * @param highLevelSimpleClient
+        *            The high-level simple client
         * @param menuName
         *            The name of the menu item
         * @param page
diff --git a/src/main/java/net/pterodactylus/sone/web/page/RedirectPage.java b/src/main/java/net/pterodactylus/sone/web/page/RedirectPage.java
new file mode 100644 (file)
index 0000000..2ce34f9
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * Sone - RedirectPage.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.page;
+
+/**
+ * Page implementation that redirects the user to another URL.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class RedirectPage implements Page {
+
+       /** The original path. */
+       private String originalPath;
+
+       /** The path to redirect the browser to. */
+       private String newPath;
+
+       /**
+        * Creates a new redirect page.
+        *
+        * @param originalPath
+        *            The original path
+        * @param newPath
+        *            The path to redirect the browser to
+        */
+       public RedirectPage(String originalPath, String newPath) {
+               this.originalPath = originalPath;
+               this.newPath = newPath;
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public String getPath() {
+               return originalPath;
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public Response handleRequest(Request request) {
+               return new RedirectResponse(newPath);
+       }
+
+}
index 9b40deb..06fc9fc 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * shortener - TemplatePage.java - Copyright © 2010 David Roden
+ * Sone - StaticTemplatePage.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
 
 package net.pterodactylus.sone.web.page;
 
-import java.io.StringWriter;
-import java.util.Collection;
-import java.util.Collections;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
-import net.pterodactylus.sone.web.page.Page.Request.Method;
+import net.pterodactylus.util.io.Closer;
 import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.template.TemplateContextFactory;
-import freenet.clients.http.LinkEnabledCallback;
-import freenet.clients.http.PageMaker;
-import freenet.clients.http.PageNode;
-import freenet.clients.http.ToadletContext;
-import freenet.l10n.BaseL10n;
 
 /**
- * Base class for all {@link Page}s that are rendered with {@link Template}s.
+ * A template page is a single page that is created from a {@link Template} but
+ * does not necessarily return HTML.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
-public class TemplatePage implements Page, LinkEnabledCallback {
+public class TemplatePage implements Page {
 
        /** The logger. */
        private static final Logger logger = Logging.getLogger(TemplatePage.class);
 
-       /** The path of the page. */
+       /** The path of this page. */
        private final String path;
 
+       /** The content type of this page. */
+       private final String contentType;
+
        /** The template context factory. */
        private final TemplateContextFactory templateContextFactory;
 
        /** The template to render. */
        private final Template template;
 
-       /** The L10n handler. */
-       private final BaseL10n l10n;
-
-       /** The l10n key for the page title. */
-       private final String pageTitleKey;
-
-       /** Where to redirect for invalid form passwords. */
-       private final String invalidFormPasswordRedirectTarget;
-
        /**
         * Creates a new template page.
         *
         * @param path
         *            The path of the page
+        * @param contentType
+        *            The content type of the page
         * @param templateContextFactory
         *            The template context factory
         * @param template
         *            The template to render
-        * @param l10n
-        *            The L10n handler
-        * @param pageTitleKey
-        *            The l10n key of the title page
-        * @param invalidFormPasswordRedirectTarget
-        *            The target to redirect to if a POST request does not contain
-        *            the correct form password
         */
-       public TemplatePage(String path, TemplateContextFactory templateContextFactory, Template template, BaseL10n l10n, String pageTitleKey, String invalidFormPasswordRedirectTarget) {
+       public TemplatePage(String path, String contentType, TemplateContextFactory templateContextFactory, Template template) {
                this.path = path;
+               this.contentType = contentType;
                this.templateContextFactory = templateContextFactory;
                this.template = template;
-               this.l10n = l10n;
-               this.pageTitleKey = pageTitleKey;
-               this.invalidFormPasswordRedirectTarget = invalidFormPasswordRedirectTarget;
        }
 
        /**
@@ -101,156 +85,22 @@ public class TemplatePage implements Page, LinkEnabledCallback {
         */
        @Override
        public Response handleRequest(Request request) {
-               String redirectTarget = getRedirectTarget(request);
-               if (redirectTarget != null) {
-                       return new RedirectResponse(redirectTarget);
-               }
-
-               ToadletContext toadletContext = request.getToadletContext();
-               if (request.getMethod() == Method.POST) {
-                       /* require form password. */
-                       String formPassword = request.getHttpRequest().getPartAsStringFailsafe("formPassword", 32);
-                       if (!formPassword.equals(toadletContext.getContainer().getFormPassword())) {
-                               return new RedirectResponse(invalidFormPasswordRedirectTarget);
-                       }
-               }
-               PageMaker pageMaker = toadletContext.getPageMaker();
-               PageNode pageNode = pageMaker.getPageNode(l10n.getString(pageTitleKey), toadletContext);
-               for (String styleSheet : getStyleSheets()) {
-                       pageNode.addCustomStyleSheet(styleSheet);
-               }
-               String shortcutIcon = getShortcutIcon();
-               if (shortcutIcon != null) {
-                       pageNode.addForwardLink("icon", shortcutIcon);
-               }
-
-               TemplateContext templateContext = templateContextFactory.createTemplateContext();
-               templateContext.mergeContext(template.getInitialContext());
+               ByteArrayOutputStream responseOutputStream = new ByteArrayOutputStream();
+               OutputStreamWriter responseWriter = null;
                try {
-                       long start = System.nanoTime();
-                       processTemplate(request, templateContext);
-                       long finish = System.nanoTime();
-                       logger.log(Level.FINEST, "Template was rendered in " + ((finish - start) / 1000) / 1000.0 + "ms.");
-               } catch (RedirectException re1) {
-                       return new RedirectResponse(re1.getTarget());
-               }
-
-               StringWriter stringWriter = new StringWriter();
-               template.render(templateContext, stringWriter);
-               pageNode.content.addChild("%", stringWriter.toString());
-
-               postProcess(request, templateContext);
-
-               return new Response(200, "OK", "text/html", pageNode.outer.generate());
-       }
-
-       /**
-        * Can be overridden to return a custom set of style sheets that are to be
-        * included in the page’s header.
-        *
-        * @return Additional style sheets to load
-        */
-       protected Collection<String> getStyleSheets() {
-               return Collections.emptySet();
-       }
-
-       /**
-        * Returns the name of the shortcut icon to include in the page’s header.
-        *
-        * @return The URL of the shortcut icon, or {@code null} for no icon
-        */
-       protected String getShortcutIcon() {
-               return null;
-       }
-
-       /**
-        * Can be overridden when extending classes need to set variables in the
-        * template before it is rendered.
-        *
-        * @param request
-        *            The request that is rendered
-        * @param templateContext
-        *            The template context to set variables in
-        * @throws RedirectException
-        *             if the processing page wants to redirect after processing
-        */
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
-               /* do nothing. */
-       }
-
-       /**
-        * This method will be called after
-        * {@link #processTemplate(net.pterodactylus.sone.web.page.Page.Request, TemplateContext)}
-        * has processed the template and the template was rendered. This method
-        * will not be called if
-        * {@link #processTemplate(net.pterodactylus.sone.web.page.Page.Request, TemplateContext)}
-        * throws a {@link RedirectException}!
-        *
-        * @param request
-        *            The request being processed
-        * @param templateContext
-        *            The template context that supplied the rendered data
-        */
-       protected void postProcess(Request request, TemplateContext templateContext) {
-               /* do nothing. */
-       }
-
-       /**
-        * Can be overridden to redirect the user to a different page, in case a log
-        * in is required, or something else is wrong.
-        *
-        * @param request
-        *            The request that is processed
-        * @return The URL to redirect to, or {@code null} to not redirect
-        */
-       protected String getRedirectTarget(Page.Request request) {
-               return null;
-       }
-
-       //
-       // INTERFACE LinkEnabledCallback
-       //
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public boolean isEnabled(ToadletContext toadletContext) {
-               return true;
-       }
-
-       /**
-        * Exception that can be thrown to signal that a subclassed {@link Page}
-        * wants to redirect the user during the
-        * {@link TemplatePage#processTemplate(net.pterodactylus.sone.web.page.Page.Request, TemplateContext)}
-        * method call.
-        *
-        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
-        */
-       public class RedirectException extends Exception {
-
-               /** The target to redirect to. */
-               private final String target;
-
-               /**
-                * Creates a new redirect exception.
-                *
-                * @param target
-                *            The target of the redirect
-                */
-               public RedirectException(String target) {
-                       this.target = target;
+                       responseWriter = new OutputStreamWriter(responseOutputStream, "UTF-8");
+                       TemplateContext templateContext = templateContextFactory.createTemplateContext();
+                       templateContext.set("request", request);
+                       template.render(templateContext, responseWriter);
+               } catch (IOException ioe1) {
+                       logger.log(Level.WARNING, "Could not render template for path “" + path + "”!", ioe1);
+               } finally {
+                       Closer.close(responseWriter);
+                       Closer.close(responseOutputStream);
                }
-
-               /**
-                * Returns the target to redirect to.
-                *
-                * @return The target to redirect to
-                */
-               public String getTarget() {
-                       return target;
-               }
-
+               ByteArrayInputStream responseInputStream = new ByteArrayInputStream(responseOutputStream.toByteArray());
+               /* no need to close a ByteArrayInputStream. */
+               return new Response(200, "OK", contentType, null, responseInputStream);
        }
 
 }
index a5ab0c1..a5f423e 100644 (file)
@@ -25,12 +25,21 @@ Navigation.Menu.Item.About.Tooltip=Information about Sone
 
 Page.About.Title=About - Sone
 Page.About.Page.Title=About
+Page.About.Flattr.Description=If you like Sone and you would like to reward me, you can use the Flattr button at the bottom of each page. Flattr is a non-anonymous micro payment that acts like an internet tip jar where the amount each user spends is limited (lowest being 2 € per month). More information can be found on {link}flattr.com{/link}.
+Page.About.Homepage.Title=Homepage
+Page.About.Homepage.Description=You can find more information and the source code of Sone on the {link}homepage{/link}.
+Page.About.License.Title=License
 
 Page.Options.Title=Options - Sone
 Page.Options.Page.Title=Options
 Page.Options.Page.Description=These options influence the runtime behaviour of the Sone plugin.
+Page.Options.Section.SoneSpecificOptions.Title=Sone-specific Options
+Page.Options.Section.SoneSpecificOptions.NotLoggedIn=These options are only available if you are {link}logged in{/link}.
+Page.Options.Section.SoneSpecificOptions.LoggedIn=These options are only available while you are logged in and they are only valid for the Sone you are logged in as.
+Page.Options.Option.AutoFollow.Description=If a new Sone is discovered, follow it automatically.
 Page.Options.Section.RuntimeOptions.Title=Runtime Behaviour
 Page.Options.Option.InsertionDelay.Description=The number of seconds the Sone inserter waits after a modification of a Sone before it is being inserted.
+Page.Options.Option.PostsPerPage.Description=The number of posts to display on a page before pagination controls are being shown.
 Page.Options.Section.TrustOptions.Title=Trust Settings
 Page.Options.Option.PositiveTrust.Description=The amount of positive trust you want to assign to other Sones by clicking the checkmark below a post or reply.
 Page.Options.Option.NegativeTrust.Description=The amount of trust you want to assign to other Sones by clicking the red X below a post or reply. This value should be negative.
@@ -127,6 +136,7 @@ Page.ViewSone.PostList.Title=Posts by {sone}
 Page.ViewSone.PostList.Text.NoPostYet=This Sone has not yet posted anything.
 Page.ViewSone.Profile.Title=Profile
 Page.ViewSone.Profile.Label.Name=Name
+Page.ViewSone.Replies.Title=Replies to Posts
 
 Page.ViewPost.Title=View Post - Sone
 Page.ViewPost.Page.Title=View Post by {sone}
@@ -212,6 +222,12 @@ 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.Search.Title=Search - Sone
+Page.Search.Page.Title=Search Results
+Page.Search.Text.SoneHits=The following Sones match your search terms.
+Page.Search.Text.PostHits=The following posts match your search terms.
+Page.Search.Text.NoHits=No Sones or posts matched your search terms.
+
 Page.NoPermission.Title=Unauthorized Access - Sone
 Page.NoPermission.Page.Title=Unauthorized Access
 Page.NoPermission.Text.NoPermission=You tried to do something that you do not have sufficient authorization for. Please refrain from such actions in the future or we will be forced to take counter-measures!
@@ -227,10 +243,12 @@ 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.Search.Button.Search=Search
+
 View.CreateSone.Text.WotIdentityRequired=To create a Sone you need an identity from the {link}Web of Trust plugin{/link}.
 View.CreateSone.Select.Default=Select an identity
 View.CreateSone.Text.NoIdentities=You do not have any Web of Trust identities. Please head over to the {link}Web of Trust plugin{/link} and create an identity.
-View.CreateSone.Text.NoNonSoneIdentities=You do not have any Web of Trust identities that are not already a Sone. Please head over to the {link}Web of Trust plugin{/link} and create an identity.
+View.CreateSone.Text.NoNonSoneIdentities=You do not have any Web of Trust identities that are not already a Sone. Use one of the remaining Web of Trust identities to create a new Sone or head over to the {link}Web of Trust plugin{/link} to create a new identity.
 View.CreateSone.Button.Create=Create Sone
 View.CreateSone.Text.Error.NoIdentity=You have not selected an identity.
 
@@ -257,6 +275,7 @@ View.Post.Reply.DeleteLink=Delete
 View.Post.LikeLink=Like
 View.Post.UnlikeLink=Unlike
 View.Post.ShowSource=Toggle Parser
+View.Post.NotDownloaded=This post has not yet been downloaded, or it has been deleted.
 
 View.UpdateStatus.Text.ChooseSenderIdentity=Choose the sender identity
 
@@ -273,6 +292,23 @@ View.UploadImage.Label.Title=Title:
 View.UploadImage.Label.Description=Description:
 View.UploadImage.Button.UploadImage=Upload Image
 
+View.Time.InTheFuture=in the future
+View.Time.AFewSecondsAgo=a few seconds ago
+View.Time.HalfAMinuteAgo=about half a minute ago
+View.Time.AMinuteAgo=about a minute ago
+View.Time.XMinutesAgo=${min} minutes ago
+View.Time.HalfAnHourAgo=half an hour ago
+View.Time.AnHourAgo=about an hour ago
+View.Time.XHoursAgo=${hour} hours ago
+View.Time.ADayAgo=about a day ago
+View.Time.XDaysAgo=${day} days ago
+View.Time.AWeekAgo=about a week ago
+View.Time.XWeeksAgo=${week} week ago
+View.Time.AMonthAgo=about a month ago
+View.Time.XMonthsAgo=${month} months ago
+View.Time.AYearAgo=about a year ago
+View.Time.XYearsAgo=${year} years ago
+
 WebInterface.DefaultText.StatusUpdate=What’s on your mind?
 WebInterface.DefaultText.Message=Write a Message…
 WebInterface.DefaultText.Reply=Write a Reply…
@@ -293,6 +329,10 @@ WebInterface.DefaultText.UploadImage.Title=Image title
 WebInterface.DefaultText.UploadImage.Description=Image description
 WebInterface.DefaultText.EditImage.Title=Image title
 WebInterface.DefaultText.EditImage.Description=Image description
+WebInterface.DefaultText.Option.PostsPerPage=Number of posts to show on a page
+WebInterface.DefaultText.Option.PositiveTrust=The positive trust to assign
+WebInterface.DefaultText.Option.NegativeTrust=The negative trust to assign
+WebInterface.DefaultText.Option.TrustComment=The comment to set in the web of trust
 WebInterface.Confirmation.DeletePostButton=Yes, delete!
 WebInterface.Confirmation.DeleteReplyButton=Yes, delete!
 WebInterface.SelectBox.Choose=Choose…
@@ -313,7 +353,7 @@ 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.NewReply.Text=New replies have been discovered for posts by the following Sones:
 Notification.SoneIsBeingRescued.Text=The following Sones are currently being rescued:
 Notification.SoneRescued.Text=The following Sones have been rescued:
 Notification.SoneRescued.Text.RememberToUnlock=Please remember to control the posts and replies you have given and don’t forget to unlock your Sones!
index ab242a2..9470850 100644 (file)
@@ -125,6 +125,10 @@ textarea {
        float: right;
 }
 
+#sone #notification-area .notification .hidden {
+       display: none;
+}
+
 #sone #plugin-warning {
        border: solid 0.5em red;
        padding: 0.5em;
@@ -579,6 +583,19 @@ textarea {
        display: inline;
 }
 
+#sone #search {
+       text-align: right;
+}
+
+#sone #search input[type=text] {
+       width: 35em;
+}
+
+#sone #sone-results + #sone #post-results {
+       clear: both;
+       padding-top: 1em;
+}
+
 #sone #tail {
        margin-top: 1em;
        border-top: solid 1px #ccc;
diff --git a/src/main/resources/static/images/sone-avatar.png b/src/main/resources/static/images/sone-avatar.png
new file mode 100644 (file)
index 0000000..0339b5a
Binary files /dev/null and b/src/main/resources/static/images/sone-avatar.png differ
index 9ef6812..d58f17f 100644 (file)
@@ -33,11 +33,12 @@ function registerInputTextareaSwap(inputElement, defaultText, inputFieldName, op
                                inputField.val(defaultText);
                        }
                }).hide().data("inputField", $(this)).val($(this).val());
-               $(this).after(textarea);
+               $(this).data("textarea", textarea).after(textarea);
                (function(inputField, textarea) {
                        inputField.focus(function() {
                                $(this).hide().attr("disabled", "disabled");
-                               textarea.show().focus();
+                               /* no, show(), “display: block” is not what I need. */
+                               textarea.attr("style", "display: inline").focus();
                        });
                        if (inputField.val() == "") {
                                inputField.addClass("default");
@@ -49,6 +50,7 @@ function registerInputTextareaSwap(inputElement, defaultText, inputFieldName, op
                        $(inputField.get(0).form).submit(function() {
                                inputField.attr("disabled", "disabled");
                                if (!optional && (textarea.val() == "")) {
+                                       inputField.removeAttr("disabled").focus();
                                        return false;
                                }
                        });
@@ -64,11 +66,11 @@ function registerInputTextareaSwap(inputElement, defaultText, inputFieldName, op
  * @param element
  *            The element to add a “comment” link to
  */
-function addCommentLink(postId, element, insertAfterThisElement) {
+function addCommentLink(postId, author, element, insertAfterThisElement) {
        if (($(element).find(".show-reply-form").length > 0) || (getPostElement(element).find(".create-reply").length == 0)) {
                return;
        }
-       commentElement = (function(postId) {
+       commentElement = (function(postId, author) {
                separator = $("<span> · </span>").addClass("separator");
                var commentElement = $("<div><span>Comment</span></div>").addClass("show-reply-form").click(function() {
                        replyElement = $("#sone .post#" + postId + " .create-reply");
@@ -83,10 +85,11 @@ function addCommentLink(postId, element, insertAfterThisElement) {
                                        replyElement.removeClass("light");
                                });
                        })(replyElement);
-                       replyElement.find("input.reply-input").focus();
+                       textArea = replyElement.find("input.reply-input").focus().data("textarea");
+                       textArea.val(textArea.val() + "@sone://" + author + " ");
                });
                return commentElement;
-       })(postId);
+       })(postId, author);
        $(insertAfterThisElement).after(commentElement.clone(true));
        $(insertAfterThisElement).after(separator);
 }
@@ -216,7 +219,8 @@ function enhanceDeletePostButton(button, postId, text) {
                        if (data.success) {
                                $("#sone .post#" + postId).slideUp();
                        } else if (data.error == "invalid-post-id") {
-                               alert("Invalid post ID given!");
+                               /* pretend the post is already gone. */
+                               getPost(postId).slideUp();
                        } else if (data.error == "auth-required") {
                                alert("You need to be logged in.");
                        } else if (data.error == "not-authorized") {
@@ -247,7 +251,8 @@ function enhanceDeleteReplyButton(button, replyId, text) {
                        if (data.success) {
                                $("#sone .reply#" + replyId).slideUp();
                        } else if (data.error == "invalid-reply-id") {
-                               alert("Invalid reply ID given!");
+                               /* pretend the reply is already gone. */
+                               getReply(replyId).slideUp();
                        } else if (data.error == "auth-required") {
                                alert("You need to be logged in.");
                        } else if (data.error == "not-authorized") {
@@ -263,6 +268,19 @@ function getFormPassword() {
        return $("#sone #formPassword").text();
 }
 
+/**
+ * Returns the element of the Sone with the given ID.
+ *
+ * @param soneId
+ *            The ID of the Sone
+ * @returns All Sone elements with the given ID
+ */
+function getSone(soneId) {
+       return $("#sone .sone").filter(function(index) {
+               return $(".id").text() == soneId;
+       });
+}
+
 function getSoneElement(element) {
        return $(element).closest(".sone");
 }
@@ -331,6 +349,17 @@ function getPostAuthor(element) {
        return getPostElement(element).find(".post-author").text();
 }
 
+/**
+ * Returns the element of the reply with the given ID.
+ *
+ * @param replyId
+ *            The ID of the reply
+ * @returns The element of the reply
+ */
+function getReply(replyId) {
+       return $("#sone .reply#" + replyId);
+}
+
 function getReplyElement(element) {
        return $(element).closest(".reply");
 }
@@ -354,6 +383,39 @@ function getReplyAuthor(element) {
        return getReplyElement(element).find(".reply-author").text();
 }
 
+/**
+ * Returns the notification with the given ID.
+ *
+ * @param notificationId
+ *            The ID of the notification
+ * @returns The notification element
+ */
+function getNotification(notificationId) {
+       return $("#sone #notification-area .notification#" + notificationId);
+}
+
+/**
+ * Returns the notification element closest to the given element.
+ *
+ * @param element
+ *            The element to get the closest notification of
+ * @return The closest notification element
+ */
+function getNotificationElement(element) {
+       return $(element).closest(".notification");
+}
+
+/**
+ * Returns the ID of the notification element.
+ *
+ * @param notificationElement
+ *            The notification element
+ * @returns The ID of the notification
+ */
+function getNotificationId(notificationElement) {
+       return $(notificationElement).attr("id");
+}
+
 function likePost(postId) {
        $.getJSON("like.ajax", { "type": "post", "post" : postId, "formPassword": getFormPassword() }, function(data, textStatus) {
                if ((data == null) || !data.success) {
@@ -560,25 +622,6 @@ function postReply(sender, postId, text, callbackFunction) {
 }
 
 /**
- * Requests information about the reply with the given ID.
- *
- * @param replyId
- *            The ID of the reply
- * @param callbackFunction
- *            A callback function (parameters soneId, soneName, replyTime,
- *            replyDisplayTime, text, html)
- */
-function getReply(replyId, callbackFunction) {
-       $.getJSON("getReply.ajax", { "reply" : replyId }, function(data, textStatus) {
-               if ((data != null) && data.success) {
-                       callbackFunction(data.soneId, data.soneName, data.time, data.displayTime, data.text, data.html);
-               }
-       }, function(xmlHttpRequest, textStatus, error) {
-               /* ignore error. */
-       });
-}
-
-/**
  * Ajaxifies the given Sone by enhancing all eligible elements with AJAX.
  *
  * @param soneElement
@@ -624,7 +667,7 @@ function ajaxifySone(soneElement) {
 
        /* mark Sone as known when clicking it. */
        $(soneElement).click(function() {
-               markSoneAsKnown(soneElement);
+               markSoneAsKnown(this);
        });
 }
 
@@ -639,6 +682,8 @@ function ajaxifyPost(postElement) {
                return false;
        });
        $(postElement).find(".create-reply button:submit").click(function() {
+               button = $(this);
+               button.attr("disabled", "disabled");
                sender = $(this.form).find(":input[name=sender]").val();
                inputField = $(this.form).find(":input[name=text]:enabled").get(0);
                postId = getPostId(this);
@@ -655,6 +700,7 @@ function ajaxifyPost(postElement) {
                                } else {
                                        alert(error);
                                }
+                               button.removeAttr("disabled");
                        });
                })(sender, postId, text, inputField);
                return false;
@@ -712,12 +758,15 @@ function ajaxifyPost(postElement) {
        });
 
        /* add “comment” link. */
-       addCommentLink(getPostId(postElement), postElement, $(postElement).find(".post-status-line .time"));
+       addCommentLink(getPostId(postElement), getPostAuthor(postElement), postElement, $(postElement).find(".post-status-line .time"));
 
        /* process all replies. */
+       replyIds = [];
        $(postElement).find(".reply").each(function() {
+               replyIds.push(getReplyId(this));
                ajaxifyReply(this);
        });
+       updateReplyTimes(replyIds.join(","));
 
        /* process reply input fields. */
        getTranslation("WebInterface.DefaultText.Reply", function(text) {
@@ -769,7 +818,7 @@ function ajaxifyReply(replyElement) {
                        });
                });
        })(replyElement);
-       addCommentLink(getPostId(replyElement), replyElement, $(replyElement).find(".reply-status-line .time"));
+       addCommentLink(getPostId(replyElement), getReplyAuthor(replyElement), replyElement, $(replyElement).find(".reply-status-line .time"));
 
        /* convert “show source” link into javascript function. */
        $(replyElement).find(".show-reply-source").each(function() {
@@ -833,6 +882,90 @@ function ajaxifyNotification(notification) {
        return notification;
 }
 
+/**
+ * Retrieves element IDs from notification elements.
+ *
+ * @param notification
+ *            The notification element
+ * @param selector
+ *            The selector of the element containing the ID as text
+ * @returns All extracted IDs
+ */
+function getElementIds(notification, selector) {
+       elementIds = [];
+       $(selector, notification).each(function() {
+               elementIds.push($(this).text());
+       });
+       return elementIds;
+}
+
+/**
+ * Compares the given notification elements and calls {@link #markSoneAsKnown()}
+ * for every ID that is contained in the old notification but not in the new.
+ *
+ * @param oldNotification
+ *            The old notification element
+ * @param newNotification
+ *            The new notification element
+ */
+function checkForRemovedSones(oldNotification, newNotification) {
+       if (getNotificationId(oldNotification) != "new-sone-notification") {
+               return;
+       }
+       oldIds = getElementIds(oldNotification, ".sone-id");
+       newIds = getElementIds(newNotification, ".sone-id");
+       $.each(oldIds, function(index, value) {
+               if ($.inArray(value, newIds) == -1) {
+                       markSoneAsKnown(getSone(value), true);
+               }
+       });
+}
+
+/**
+ * Compares the given notification elements and calls {@link #markPostAsKnown()}
+ * for every ID that is contained in the old notification but not in the new.
+ *
+ * @param oldNotification
+ *            The old notification element
+ * @param newNotification
+ *            The new notification element
+ */
+function checkForRemovedPosts(oldNotification, newNotification) {
+       if (getNotificationId(oldNotification) != "new-post-notification") {
+               return;
+       }
+       oldIds = getElementIds(oldNotification, ".post-id");
+       newIds = getElementIds(newNotification, ".post-id");
+       $.each(oldIds, function(index, value) {
+               if ($.inArray(value, newIds) == -1) {
+                       markPostAsKnown(getPost(value), true);
+               }
+       });
+}
+
+/**
+ * Compares the given notification elements and calls
+ * {@link #markReplyAsKnown()} for every ID that is contained in the old
+ * notification but not in the new.
+ *
+ * @param oldNotification
+ *            The old notification element
+ * @param newNotification
+ *            The new notification element
+ */
+function checkForRemovedReplies(oldNotification, newNotification) {
+       if (getNotificationId(oldNotification) != "new-replies-notification") {
+               return;
+       }
+       oldIds = getElementIds(oldNotification, ".reply-id");
+       newIds = getElementIds(newNotification, ".reply-id");
+       $.each(oldIds, function(index, value) {
+               if ($.inArray(value, newIds) == -1) {
+                       markReplyAsKnown(getReply(value), true);
+               }
+       });
+}
+
 function getStatus() {
        $.getJSON("getStatus.ajax", {"loadAllSones": isKnownSonesPage()}, function(data, textStatus) {
                if ((data != null) && data.success) {
@@ -840,9 +973,45 @@ function getStatus() {
                        $.each(data.sones, function(index, value) {
                                updateSoneStatus(value.id, value.name, value.status, value.modified, value.locked, value.lastUpdatedUnknown ? null : value.lastUpdated);
                        });
+                       /* search for removed notifications. */
+                       $("#sone #notification-area .notification").each(function() {
+                               notificationId = $(this).attr("id");
+                               foundNotification = false;
+                               $.each(data.notifications, function(index, value) {
+                                       if (value.id == notificationId) {
+                                               foundNotification = true;
+                                               return false;
+                                       }
+                               });
+                               if (!foundNotification) {
+                                       if (notificationId == "new-sone-notification") {
+                                               $(".sone-id", this).each(function(index, element) {
+                                                       soneId = $(this).text();
+                                                       markSoneAsKnown(getSone(soneId), true);
+                                               });
+                                       } else if (notificationId == "new-post-notification") {
+                                               $(".post-id", this).each(function(index, element) {
+                                                       postId = $(this).text();
+                                                       markPostAsKnown(getPost(postId), true);
+                                               });
+                                       } else if (notificationId == "new-replies-notification") {
+                                               $(".reply-id", this).each(function(index, element) {
+                                                       replyId = $(this).text();
+                                                       markReplyAsKnown(getReply(replyId), true);
+                                               });
+                                       }
+                                       $(this).slideUp("normal", function() {
+                                               $(this).remove();
+                                               /* remove activity when no notifications are visible. */
+                                               if ($("#sone #notification-area .notification").length == 0) {
+                                                       resetActivity();
+                                               }
+                                       });
+                               }
+                       });
                        /* process notifications. */
                        $.each(data.notifications, function(index, value) {
-                               oldNotification = $("#sone #notification-area .notification#" + value.id);
+                               oldNotification = getNotification(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)) {
@@ -850,15 +1019,15 @@ function getStatus() {
                                                notification.find(".short-text").toggleClass("hidden", opened);
                                                notification.find(".text").toggleClass("hidden", !opened);
                                        }
+                                       checkForRemovedSones(oldNotification, notification);
+                                       checkForRemovedPosts(oldNotification, notification);
+                                       checkForRemovedReplies(oldNotification, notification);
                                        oldNotification.replaceWith(notification.show());
                                } else {
                                        $("#sone #notification-area").append(notification);
                                        notification.slideDown();
+                                       setActivity();
                                }
-                               setActivity();
-                       });
-                       $.each(data.removedNotifications, function(index, value) {
-                               $("#sone #notification-area .notification#" + value.id).slideUp();
                        });
                        /* process new posts. */
                        $.each(data.newPosts, function(index, value) {
@@ -1017,6 +1186,7 @@ function loadNewPost(postId, soneId, recipientId, time) {
                                newPost.insertBefore(firstOlderPost);
                        }
                        ajaxifyPost(newPost);
+                       updatePostTimes(data.post.id);
                        newPost.slideDown();
                        setActivity();
                }
@@ -1055,6 +1225,7 @@ function loadNewReply(replyId, soneId, postId, postSoneId) {
                                        }
                                }
                                ajaxifyReply(newReply);
+                               updateReplyTimes(data.reply.id);
                                newReply.slideDown();
                                setActivity();
                                return false;
@@ -1068,46 +1239,133 @@ function loadNewReply(replyId, soneId, postId, postSoneId) {
  *
  * @param soneElement
  *            The Sone to mark as known
+ * @param skipRequest
+ *            true to skip the JSON request, false or omit to perform the JSON
+ *            request
  */
-function markSoneAsKnown(soneElement) {
+function markSoneAsKnown(soneElement, skipRequest) {
        if ($(".new", soneElement).length > 0) {
-               $.getJSON("maskAsKnown.ajax", {"formPassword": getFormPassword(), "type": "sone", "id": getSoneId(soneElement)}, function(data, textStatus) {
-                       $(soneElement).removeClass("new");
-               });
+               if ((typeof skipRequest != "undefined") && !skipRequest) {
+                       $.getJSON("maskAsKnown.ajax", {"formPassword": getFormPassword(), "type": "sone", "id": getSoneId(soneElement)}, function(data, textStatus) {
+                               $(soneElement).removeClass("new");
+                       });
+               }
        }
 }
 
-function markPostAsKnown(postElements) {
+function markPostAsKnown(postElements, skipRequest) {
        $(postElements).each(function() {
                postElement = this;
                if ($(postElement).hasClass("new")) {
                        (function(postElement) {
                                $(postElement).removeClass("new");
                                $(".click-to-show", postElement).removeClass("new");
-                               $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "post", "id": getPostId(postElement)});
+                               if ((typeof skipRequest == "undefined") || !skipRequest) {
+                                       $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "post", "id": getPostId(postElement)});
+                               }
                        })(postElement);
                }
        });
        markReplyAsKnown($(postElements).find(".reply"));
 }
 
-function markReplyAsKnown(replyElements) {
+function markReplyAsKnown(replyElements, skipRequest) {
        $(replyElements).each(function() {
                replyElement = this;
                if ($(replyElement).hasClass("new")) {
                        (function(replyElement) {
                                $(replyElement).removeClass("new");
-                               $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "reply", "id": getReplyId(replyElement)});
+                               if ((typeof skipRequest == "undefined") || !skipRequest) {
+                                       $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "reply", "id": getReplyId(replyElement)});
+                               }
                        })(replyElement);
                }
        });
 }
 
+/**
+ * Updates the time of the post with the given ID.
+ *
+ * @param postId
+ *            The ID of the post to update
+ * @param timeText
+ *            The text of the time to show
+ * @param refreshTime
+ *            The refresh time after which to request a new time (in seconds)
+ * @param tooltip
+ *            The tooltip to show
+ */
+function updatePostTime(postId, timeText, refreshTime, tooltip) {
+       if (!getPost(postId).is(":visible")) {
+               return;
+       }
+       getPost(postId).find(".post-status-line > .time a").html(timeText).attr("title", tooltip);
+       (function(postId, refreshTime) {
+               setTimeout(function() {
+                       updatePostTimes(postId);
+               }, refreshTime * 1000);
+       })(postId, refreshTime);
+}
+
+/**
+ * Requests new rendered times for the posts with the given IDs.
+ *
+ * @param postIds
+ *            Comma-separated post IDs
+ */
+function updatePostTimes(postIds) {
+       $.getJSON("getTimes.ajax", { "posts" : postIds }, function(data, textStatus) {
+               if ((data != null) && data.success) {
+                       $.each(data.postTimes, function(index, value) {
+                               updatePostTime(index, value.timeText, value.refreshTime, value.tooltip);
+                       });
+               }
+       });
+}
+
+/**
+ * Updates the time of the reply with the given ID.
+ *
+ * @param postId
+ *            The ID of the reply to update
+ * @param timeText
+ *            The text of the time to show
+ * @param refreshTime
+ *            The refresh time after which to request a new time (in seconds)
+ * @param tooltip
+ *            The tooltip to show
+ */
+function updateReplyTime(replyId, timeText, refreshTime, tooltip) {
+       getReply(replyId).find(".reply-status-line > .time").html(timeText).attr("title", tooltip);
+       (function(replyId, refreshTime) {
+               setTimeout(function() {
+                       updateReplyTimes(replyId);
+               }, refreshTime * 1000);
+       })(replyId, refreshTime);
+}
+
+/**
+ * Requests new rendered times for the posts with the given IDs.
+ *
+ * @param postIds
+ *            Comma-separated post IDs
+ */
+function updateReplyTimes(replyIds) {
+       $.getJSON("getTimes.ajax", { "replies" : replyIds }, function(data, textStatus) {
+               if ((data != null) && data.success) {
+                       $.each(data.replyTimes, function(index, value) {
+                               updateReplyTime(index, value.timeText, value.refreshTime, value.tooltip);
+                       });
+               }
+       });
+}
+
 function resetActivity() {
        title = document.title;
        if (title.indexOf('(') == 0) {
                setTitle(title.substr(title.indexOf(' ') + 1));
        }
+       iconBlinking = false;
 }
 
 function setActivity() {
@@ -1146,7 +1404,7 @@ var iconBlinking = false;
  * showing the activity state, it is returned to normal.
  */
 function toggleIcon() {
-       if (focus) {
+       if (focus || !iconBlinking) {
                if (iconActive) {
                        changeIcon("images/icon.png");
                        iconActive = false;
@@ -1154,7 +1412,6 @@ function toggleIcon() {
                iconBlinking = false;
        } else {
                iconActive = !iconActive;
-               console.log("showing icon: " + iconActive);
                changeIcon(iconActive ? "images/icon-activity.png" : "images/icon.png");
                setTimeout(toggleIcon, 1500);
        }
@@ -1298,15 +1555,15 @@ $(document).ready(function() {
                        return false;
                });
                $("#sone #update-status").submit(function() {
+                       button = $("button:submit", this);
+                       button.attr("disabled", "disabled");
                        if ($(this).find(":input.default:enabled").length > 0) {
                                return false;
                        }
                        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, data.sone, data.recipient);
-                               }
+                               button.removeAttr("disabled");
                        });
                        $(this).find(":input[name=sender]").val(getCurrentSoneId());
                        $(this).find(":input[name=text]:enabled").val("").blur();
@@ -1316,6 +1573,11 @@ $(document).ready(function() {
                });
        });
 
+       /* ajaxify the search input field. */
+       getTranslation("WebInterface.DefaultText.Search", function(defaultText) {
+               registerInputTextareaSwap("#sone #search input[name=query]", defaultText, "query", false, true);
+       });
+
        /* ajaxify input field on “view Sone” page. */
        getTranslation("WebInterface.DefaultText.Message", function(defaultText) {
                registerInputTextareaSwap("#sone #post-message input[name=text]", defaultText, "text", false, false);
@@ -1329,11 +1591,7 @@ $(document).ready(function() {
                $("#sone #post-message").submit(function() {
                        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, getCurrentSoneId());
-                               }
-                       });
+                       $.getJSON("createPost.ajax", { "formPassword": getFormPassword(), "recipient": getShownSoneId(), "sender": sender, "text": text });
                        $(this).find(":input[name=sender]").val(getCurrentSoneId());
                        $(this).find(":input[name=text]:enabled").val("").blur();
                        $(this).find(".sender").hide();
@@ -1354,6 +1612,13 @@ $(document).ready(function() {
                });
        });
 
+       /* update post times. */
+       postIds = [];
+       $("#sone .post").each(function() {
+               postIds.push(getPostId(this));
+       });
+       updatePostTimes(postIds.join(","));
+
        /* hides all replies but the latest two. */
        if (!isViewPostPage()) {
                getTranslation("WebInterface.ClickToShow.Replies", function(text) {
index 96f4c8e..e04f4f2 100644 (file)
@@ -2,17 +2,20 @@
 
        <h1><%= Page.About.Page.Title|l10n|html></h1>
 
-       <p>Sone – The Freenet Social Network Plugin, Version <% version|html>, © 2010 by David ‘Bombe’ Roden.</p>
+       <p>Sone – The Freenet Social Network Plugin, Version <% version|html>, © 2010–2011 by David ‘Bombe’ Roden.</p>
 
        <p>
-               If you like Sone and you would like to reward me, you can use the
-               Flattr button at the bottom of each page. Flattr is a non-anonymous
-               micro payment that acts like an internet tip jar where the amount
-               each user spends is limited (lowest being 2 € per month). More
-               information can be found on <a href="/?_CHECKED_HTTP_=https://www.flattr.com/"
-               title="Flattr Homepage" target="_blank">flattr.com</a>.
+               <%= Page.About.Flattr.Description|l10n|html|replace needle="{link}" replacement='<a href="/?_CHECKED_HTTP_=https://www.flattr.com/" title="Flattr Homepage" target="_blank">'|replace needle="{/link}" replacement='</a>'>
        </p>
 
+       <h2><%= Page.About.Homepage.Title|l10n|html></h2>
+
+       <p>
+               <%= Page.About.Homepage.Description|l10n|html|replace needle="{link}" replacement='<a href="/USK@nwa8lHa271k2QvJ8aa0Ov7IHAV-DFOCFgmDt3X6BpCI,DuQSUZiI~agF8c-6tjsFFGuZ8eICrzWCILB60nT8KKo,AQACAAE/sone/33/">'|replace needle="{/link}" replacement='</a>'>
+       </p>
+
+       <h2><%= Page.About.License.Title|l10n|html></h2>
+
        <p>
                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
index ecde2f7..fea58a8 100644 (file)
@@ -28,7 +28,7 @@
                                registerInputTextareaSwap("#sone #edit-profile input[name=birth-year]", birthYearDefaultText, "birth-year", true, true);
                        });
                        getTranslation("WebInterface.DefaultText.FieldName", function(fieldNameDefaultText) {
-                               registerInputTextareaSwap("#sone #add-profile-field input[name=field-name]", fieldNameDefaultText, "field-name", true, true);
+                               registerInputTextareaSwap("#sone #add-profile-field input[name=field-name]", fieldNameDefaultText, "field-name", false, true);
                        });
 
                        <%foreach fields field>
index 30ce0f7..c0df5c0 100644 (file)
@@ -1,7 +1,7 @@
 <%if !identitiesWithoutSone.empty>
        <h1><%= Page.Login.CreateSone.Title|l10n|html></h1>
 
-       <p><%= View.CreateSone.Text.WotIdentityRequired|l10n|html|replace needle="{link}" replacement='<a href="/WoT/">'|replace needle="{/link}" replacement='</a>'></p>
+       <p><%= View.CreateSone.Text.WotIdentityRequired|l10n|html|replace needle="{link}" replacement='<a href="/WebOfTrust/">'|replace needle="{/link}" replacement='</a>'></p>
 
        <form id="create-sone" action="createSone.html" method="post">
                <input type="hidden" name="formPassword" value="<% formPassword|html>" />
@@ -23,8 +23,8 @@
        </form>
 <%else>
        <%if !sones.empty>
-               <p><%= View.CreateSone.Text.NoNonSoneIdentities|l10n|html|replace needle="{link}" replacement='<a href="/WoT/OwnIdentities">'|replace needle="{/link}" replacement="</a>"></p>
+               <p><%= View.CreateSone.Text.NoNonSoneIdentities|l10n|html|replace needle="{link}" replacement='<a href="/WebOfTrust/OwnIdentities">'|replace needle="{/link}" replacement="</a>"></p>
        <%else>
-               <p><%= View.CreateSone.Text.NoIdentities|l10n|html|replace needle="{link}" replacement='<a href="/WoT/OwnIdentities">'|replace needle="{/link}" replacement="</a>"></p>
+               <p><%= View.CreateSone.Text.NoIdentities|l10n|html|replace needle="{link}" replacement='<a href="/WebOfTrust/OwnIdentities">'|replace needle="{/link}" replacement="</a>"></p>
        <%/if>
 <%/if>
index a9fbb49..fe9cf34 100644 (file)
@@ -36,7 +36,7 @@
                <div id="profile" class="<%ifnull currentSone>offline<%else>online<%/if>">
                        <a class="picture" href="index.html">
                                <%ifnull !currentSone>
-                                       <img src="/WoT/GetIdenticon?identity=<% currentSone.id|html>&amp;width=80&amp;height=80" width="80" height="80" alt="Profile Avatar" />
+                                       <img src="/WebOfTrust/GetIdenticon?identity=<% currentSone.id|html>&amp;width=80&amp;height=80" width="80" height="80" alt="Profile Avatar" />
                                <%else>
                                        <img src="images/sone.png" width="80" height="80" alt="Sone is offline" />
                                <%/if>
@@ -47,4 +47,8 @@
                                        <%include include/viewSone.html>
                                </div>
                        <%/if>
+                       <form id="search" action="search.html" method="get">
+                               <input type="text" name="query" value="" />
+                               <button type="submit"><%= View.Search.Button.Search|l10n|html></button>
+                       </form>
                </div>
index ec2b587..7ab6b22 100644 (file)
@@ -4,7 +4,7 @@
        <div id="tail">
                <div class="flattr-button">
                        <a href="/?_CHECKED_HTTP_=http://flattr.com/thing/81996/Sone-The-Freenet-Social-Network-Plugin" target="_blank">
-                               <img src="images/flattr-badge-large.png" alt="Flattr Sone" title="Flattr Sone" />
+                               <img src="images/flattr-badge-large.png" width="93" height="20" alt="Flattr Sone" title="Flattr Sone" />
                        </a>
                </div>
 
index 3023566..4be1bcc 100644 (file)
@@ -3,10 +3,14 @@
        <div class="post-time hidden"><% post.time|html></div>
        <div class="post-author hidden"><% post.sone.id|html></div>
        <div class="avatar">
-               <img src="/WoT/GetIdenticon?identity=<% post.sone.id|html>&amp;width=48&height=48" width="48" height="48" alt="Avatar Image" />
+               <%if post.loaded>
+                       <img src="/WebOfTrust/GetIdenticon?identity=<% post.sone.id|html>&amp;width=48&height=48" width="48" height="48" alt="Avatar Image" />
+               <%else>
+                       <img src="images/sone-avatar.png" width="48" height="48" alt="Avatar Image" />
+               <%/if>
        </div>
        <div class="inner-part">
-               <div>
+               <div<%if !post.loaded> class="hidden"<%/if>>
                        <div class="author profile-link"><a href="viewSone.html?sone=<% post.sone.id|html>"><% post.sone.niceName|html></a></div>
                        <%ifnull !post.recipient>
                                <span class="recipient-to">→</span>
                                        <div class="recipient profile-link"><a href="viewSone.html?sone=<% post.recipient.id|html>"><% post.recipient.niceName|html></a></div>
                                <%/if>
                        <%/if>
-                       <div class="post-text raw-text<%if !raw> hidden<%/if>"><% post.text|html></div>
-                       <div class="post-text text<%if raw> hidden<%/if>"><% post.text|parse sone=post.sone></div>
+                       <% post.text|html|store key=originalText text=true>
+                       <% post.text|parse sone=post.sone|store key=parsedText text=true>
+                       <div class="post-text raw-text<%if !raw> hidden<%/if>"><% originalText></div>
+                       <div class="post-text text<%if raw> hidden<%/if>"><% parsedText></div>
                </div>
-               <div class="post-status-line status-line">
+               <div class="post-status-line status-line<%if !post.loaded> hidden<%/if>">
                        <div class="bookmarks">
                                <form class="unbookmark<%if !post.bookmarked> hidden<%/if>" action="unbookmark.html" method="post">
                                        <input type="hidden" name="formPassword" value="<% formPassword|html>" />
                        </div>
                        <span class='separator'>·</span>
                        <div class="time"><a href="viewPost.html?post=<% post.id|html>"><% post.time|date format="MMM d, yyyy, HH:mm:ss"></a></div>
-                       <span class='separator'>·</span>
-                       <div class="show-source"><a href="viewPost.html?post=<% post.id|html>&amp;raw=<%if raw>false<%else>true<%/if>"><%= View.Post.ShowSource|l10n|html></a></div>
+                       <%if ! originalText|match key=parsedText>
+                               <span class='separator'>·</span>
+                               <div class="show-source"><a href="viewPost.html?post=<% post.id|html>&amp;raw=<%if raw>false<%else>true<%/if>"><%= View.Post.ShowSource|l10n|html></a></div>
+                       <%/if>
                        <div class="likes<%if post.likes.size|match value=0> hidden<%/if>">
                                <span class='separator'>·</span>
                                <span title="<% post.likes.soneNames|html>">↑<span class="like-count"><% post.likes.size></span></span>
@@ -90,6 +98,9 @@
                                </form>
                        <%/if>
                </div>
+               <div<%if post.loaded> class="hidden"<%/if>>
+                       <%= View.Post.NotDownloaded|l10n|html>
+               </div>
                <div class="replies">
                        <%foreach post.replies reply>
                                <%include include/viewReply.html>
index cdb837c..5dc26fb 100644 (file)
@@ -3,18 +3,22 @@
        <div class="reply-time hidden"><% reply.time|html></div>
        <div class="reply-author hidden"><% reply.sone.id|html></div>
        <div class="avatar">
-               <img src="/WoT/GetIdenticon?identity=<% reply.sone.id|html>&amp;width=36&height=36" width="36" height="36" alt="Avatar Image" />
+               <img src="/WebOfTrust/GetIdenticon?identity=<% reply.sone.id|html>&amp;width=36&height=36" width="36" height="36" alt="Avatar Image" />
        </div>
        <div class="inner-part">
                <div>
                        <div class="author profile-link"><a href="viewSone.html?sone=<% reply.sone.id|html>"><% reply.sone.niceName|html></a></div>
-                       <div class="reply-text raw-text<%if !raw> hidden<%/if>"><% reply.text|html></div>
-                       <div class="reply-text text<%if raw> hidden<%/if>"><% reply.text|parse sone=reply.sone></div>
+                       <% reply.text|html|store key=originalText text=true>
+                       <% reply.text|parse sone=reply.sone|store key=parsedText text=true>
+                       <div class="reply-text raw-text<%if !raw> hidden<%/if>"><% originalText></div>
+                       <div class="reply-text text<%if raw> hidden<%/if>"><% parsedText></div>
                </div>
                <div class="reply-status-line status-line">
                        <div class="time"><% reply.time|date format="MMM d, yyyy, HH:mm:ss"></div>
-                       <span class='separator'>·</span>
-                       <div class="show-reply-source"><a href="viewPost.html?post=<% post.id|html>&amp;raw=<%if raw>false<%else>true<%/if>"><%= View.Post.ShowSource|l10n|html></a></div>
+                       <%if ! originalText|match key=parsedText>
+                               <span class='separator'>·</span>
+                               <div class="show-reply-source"><a href="viewPost.html?post=<% post.id|html>&amp;raw=<%if raw>false<%else>true<%/if>"><%= View.Post.ShowSource|l10n|html></a></div>
+                       <%/if>
                        <div class="likes<%if reply.likes.size|match value=0> hidden<%/if>">
                                <span class='separator'>·</span>
                                <span title="<% reply.likes.soneNames|html>">↑<span class="like-count"><% reply.likes.size></span></span>
index 177b470..54008dc 100644 (file)
@@ -12,6 +12,7 @@
        </form>
        <%= Notification.NewPost.Text|l10n|html>
        <%foreach posts post>
+               <div class="hidden post-id"><%post.id|html></div>
                <a class="link-<% post.id|html>" href="viewPost.html?post=<% post.id|html>"><% post.sone.niceName|html></a><%notlast>,<%/notlast><%last>.<%/last>
        <%/foreach>
 </div>
index 21a5ca0..ee8c410 100644 (file)
@@ -10,8 +10,9 @@
                <input type="hidden" name="id" value="<%foreach replies reply><% reply.id|html><%notlast> <%/notlast><%/foreach>" />
                <button type="submit" name="mark-read" value="true"><%= Notification.NewPost.Button.MarkRead|l10n|html></button>
        </form>
+       <%foreach replies reply><div class="hidden reply-id"><%reply.id|html></div><%/foreach>
        <%= Notification.NewReply.Text|l10n|html>
-       <%foreach replies reply>
-               <a class="link-<% reply.post.id|html>" href="viewPost.html?post=<% reply.post.id|html>"><% reply.sone.niceName|html></a><%notlast>,<%/notlast><%last>.<%/last>
+       <%foreach replies postGroup|replyGroup>
+               <a class="link-<% postGroup.key.id|html>" href="viewPost.html?post=<% postGroup.key.id|html>"><% postGroup.key.sone.niceName|html></a> (<%foreach postGroup.value.sones sone><%sone.niceName|html><%notlast>, <%/notlast><%/foreach>)<%notlast>, <%/notlast><%last>.<%/last>
        <%/foreach>
 </div>
index 45357b1..aaa0d14 100644 (file)
@@ -12,6 +12,7 @@
        </form>
        <%= Notification.NewSone.Text|l10n|html>
        <%foreach sones sone>
+               <div class="hidden sone-id"><% sone.id|html></div>
                <a href="viewSone.html?sone=<% sone.id|html>" title="<% sone.requestUri|html>"><% sone.niceName|html></a><%notlast>,<%/notlast><%last>.<%/last>
        <%/foreach>
 </div>
index 5c47764..db5eb80 100644 (file)
@@ -5,6 +5,18 @@
                        getTranslation("WebInterface.DefaultText.Option.InsertionDelay", function(insertionDelayDefaultText) {
                                registerInputTextareaSwap("#sone #options input[name=insertion-delay]", insertionDelayDefaultText, "insertion-delay", true, true);
                        });
+                       getTranslation("WebInterface.DefaultText.Option.PostsPerPage", function(postsPerPageText) {
+                               registerInputTextareaSwap("#sone #options input[name=posts-per-page]", postsPerPageText, "posts-per-page", true, true);
+                       });
+                       getTranslation("WebInterface.DefaultText.Option.PositiveTrust", function(positiveTrustText) {
+                               registerInputTextareaSwap("#sone #options input[name=positive-trust]", positiveTrustText, "positive-trust", true, true);
+                       });
+                       getTranslation("WebInterface.DefaultText.Option.NegativeTrust", function(negativeTrustText) {
+                               registerInputTextareaSwap("#sone #options input[name=negative-trust]", negativeTrustText, "negative-trust", true, true);
+                       });
+                       getTranslation("WebInterface.DefaultText.Option.TrustComment", function(trustCommentText) {
+                               registerInputTextareaSwap("#sone #options input[name=trust-comment]", trustCommentText, "trust-comment", true, true);
+                       });
                });
        </script>
 
        <form id="options" method="post">
                <input type="hidden" name="formPassword" value="<% formPassword|html>" />
 
+               <h2><%= Page.Options.Section.SoneSpecificOptions.Title|l10n|html></h2>
+
+               <%ifnull currentSone>
+                       <p><%= Page.Options.Section.SoneSpecificOptions.NotLoggedIn|l10n|html|replace needle="{link}" replacement='<a href="login.html">'|replace needle="{/link}" replacement='</a>'></p>
+               <%else>
+                       <p><%= Page.Options.Section.SoneSpecificOptions.LoggedIn|l10n|html></p>
+               <%/if>
+
+               <p>
+                       <input type="checkbox" name="auto-follow"<%ifnull currentSone> disabled="disabled"<%/if><%if auto-follow> checked="checked"<%/if> />
+                       <%= Page.Options.Option.AutoFollow.Description|l10n|html>
+               </p>
+
                <h2><%= Page.Options.Section.RuntimeOptions.Title|l10n|html></h2>
 
                <p><%= Page.Options.Option.InsertionDelay.Description|l10n|html></p>
                <p><input type="text" name="insertion-delay" value="<% insertion-delay|html>" /></p>
 
+               <p><%= Page.Options.Option.PostsPerPage.Description|l10n|html></p>
+               <p><input type="text" name="posts-per-page" value="<% posts-per-page|html>" /></p>
+
                <h2><%= Page.Options.Section.TrustOptions.Title|l10n|html></h2>
 
                <p><%= Page.Options.Option.PositiveTrust.Description|l10n|html></p>
diff --git a/src/main/resources/templates/search.html b/src/main/resources/templates/search.html
new file mode 100644 (file)
index 0000000..9f87072
--- /dev/null
@@ -0,0 +1,35 @@
+<%include include/head.html>
+
+       <h1><%= Page.Search.Page.Title|l10n|html></h1>
+
+       <%foreach soneHits sone>
+               <%first>
+                       <div id="sone-results">
+                               <p><%= Page.Search.Text.SoneHits|l10n|html></p>
+                               <%include include/pagination.html pagination=sonePagination pageParameter==sonePage>
+               <%/first>
+               <%include include/viewSone.html>
+               <%last>
+                               <%include include/pagination.html pagination=sonePagination pageParameter==sonePage>
+                       </div>
+               <%/last>
+       <%/foreach>
+
+       <%foreach postHits post>
+               <%first>
+                       <div id="post-results">
+                               <p><%= Page.Search.Text.PostHits|l10n|html></p>
+                               <%include include/pagination.html pagination=postPagination pageParameter==postPage>
+               <%/first>
+               <%include include/viewPost.html>
+               <%last>
+                               <%include include/pagination.html pagination=postPagination pageParameter==postPage>
+                       </div>
+               <%/last>
+       <%/foreach>
+
+       <%if soneHits.empty><%if postHits.empty>
+               <p><%= Page.Search.Text.NoHits|l10n|html></p>
+       <%/if><%/if>
+
+<%include include/tail.html>
index d2a8f6e..066adef 100644 (file)
@@ -25,7 +25,7 @@
 
                        <div class="profile-field">
                                <div class="name"><%= Page.ViewSone.Profile.Label.Name|l10n|html></div>
-                               <div class="value"><a href="/WoT/ShowIdentity?id=<% sone.id|html>"><% sone.niceName|html></a></div>
+                               <div class="value"><a href="/WebOfTrust/ShowIdentity?id=<% sone.id|html>"><% sone.niceName|html></a></div>
                        </div>
 
                        <%foreach sone.profile.fields field>
 
                <h1><%= Page.ViewSone.PostList.Title|l10n|replace needle="{sone}" replacementKey=sone.niceName|html></h1>
 
-               <div id="posts">
-                       <%:getpage parameter=postPage>
-                       <%:paginate list=sone.posts pagesize=25>
-                       <%= postPage|store key=pageParameter>
-                       <%include include/pagination.html>
-                       <%foreach pagination.items post>
-                               <%include include/viewPost.html>
-                       <%foreachelse>
-                               <div><%= Page.ViewSone.PostList.Text.NoPostYet|l10n|html></div>
-                       <%/foreach>
-                       <%include include/pagination.html>
-               </div>
+               <%foreach posts post>
+                       <%first>
+                               <div id="posts">
+                                       <%include include/pagination.html pagination=postPagination pageParameter==postPage>
+                       <%/first>
+                       <%include include/viewPost.html>
+                       <%last>
+                                       <%include include/pagination.html pagination=postPagination pageParameter==postPage>
+                               </div>
+                       <%/last>
+               <%foreachelse>
+                       <div><%= Page.ViewSone.PostList.Text.NoPostYet|l10n|html></div>
+               <%/foreach>
+
+               <%foreach repliedPosts post>
+                       <%first>
+                               <h2><%= Page.ViewSone.Replies.Title|l10n|html></h2>
+                               <div id="replied-posts">
+                                       <%include include/pagination.html pagination=repliedPostPagination pageParameter==repliedPostPage>
+                       <%/first>
+                       <%include include/viewPost.html>
+                       <%last>
+                                       <%include include/pagination.html pagination=repliedPostPagination pageParameter==repliedPostPage>
+                               </div>
+                       <%/last>
+               <%/foreach>
 
        <%/if>
 
diff --git a/src/main/resources/templates/xml/OpenSearch.xml b/src/main/resources/templates/xml/OpenSearch.xml
new file mode 100644 (file)
index 0000000..37f0d35
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
+       <ShortName>Sone</ShortName>
+       <Description>Search Sone Profiles and Posts</Description>
+       <Image width="32" height="32" type="image/png">http://<%request.httpRequest.host>/Sone/images/icon.png</Image>
+       <Url type="text/html" method="get" template="http://<%request.httpRequest.host>/Sone/search.html?query={searchTerms}" />
+</OpenSearchDescription>
diff --git a/src/test/java/net/pterodactylus/sone/text/FreenetLinkParserTest.java b/src/test/java/net/pterodactylus/sone/text/FreenetLinkParserTest.java
new file mode 100644 (file)
index 0000000..7e05fd5
--- /dev/null
@@ -0,0 +1,78 @@
+/*
+ * Sone - FreenetLinkParserTest.java - Copyright © 2010 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.text;
+
+import java.io.IOException;
+import java.io.StringReader;
+
+import junit.framework.TestCase;
+import net.pterodactylus.util.template.HtmlFilter;
+import net.pterodactylus.util.template.TemplateContextFactory;
+
+/**
+ * JUnit test case for {@link FreenetLinkParser}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class FreenetLinkParserTest extends TestCase {
+
+       /**
+        * Tests the parser.
+        *
+        * @throws IOException
+        *             if an I/O error occurs
+        */
+       public void testParser() throws IOException {
+               TemplateContextFactory templateContextFactory = new TemplateContextFactory();
+               templateContextFactory.addFilter("html", new HtmlFilter());
+               FreenetLinkParser parser = new FreenetLinkParser(null, templateContextFactory);
+               FreenetLinkParserContext context = new FreenetLinkParserContext(null);
+               Part part;
+
+               part = parser.parse(context, new StringReader("Text."));
+               assertEquals("Text.", part.toString());
+
+               part = parser.parse(context, new StringReader("Text.\nText."));
+               assertEquals("Text.\nText.", part.toString());
+
+               part = parser.parse(context, new StringReader("Text.\n\nText."));
+               assertEquals("Text.\n\nText.", part.toString());
+
+               part = parser.parse(context, new StringReader("Text.\n\n\nText."));
+               assertEquals("Text.\n\nText.", part.toString());
+
+               part = parser.parse(context, new StringReader("\nText.\n\n\nText."));
+               assertEquals("Text.\n\nText.", part.toString());
+
+               part = parser.parse(context, new StringReader("\nText.\n\n\nText.\n"));
+               assertEquals("Text.\n\nText.", part.toString());
+
+               part = parser.parse(context, new StringReader("\nText.\n\n\nText.\n\n"));
+               assertEquals("Text.\n\nText.", part.toString());
+
+               part = parser.parse(context, new StringReader("\nText.\n\n\n\nText.\n\n\n"));
+               assertEquals("Text.\n\nText.", part.toString());
+
+               part = parser.parse(context, new StringReader("\n\nText.\n\n\n\nText.\n\n\n"));
+               assertEquals("Text.\n\nText.", part.toString());
+
+               part = parser.parse(context, new StringReader("\n\nText. KSK@a text.\n\n\n\nText.\n\n\n"));
+               assertEquals("Text. <a class=\"freenet\" href=\"/KSK@a\" title=\"KSK@a\">a</a> text.\n\nText.", part.toString());
+       }
+
+}