r7050 - in dumbhippo/trunk/server: src/com/dumbhippo/dm src/com/dumbhippo/dm/annotations src/com/dumbhippo/dm/schema src/com/dumbhippo/server/dm src/com/dumbhippo/server/impl tests/com/dumbhippo/dm tests/com/dumbhippo/dm/dm



Author: otaylor
Date: 2007-12-13 11:49:05 -0600 (Thu, 13 Dec 2007)
New Revision: 7050

Added:
   dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/DMInit.java
Modified:
   dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotification.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotificationSet.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/DMSession.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/DataModel.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/LazyInitializationException.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMClassHolder.java
   dumbhippo/trunk/server/src/com/dumbhippo/server/dm/BlockDMO.java
   dumbhippo/trunk/server/src/com/dumbhippo/server/dm/PostDMO.java
   dumbhippo/trunk/server/src/com/dumbhippo/server/dm/UserDMO.java
   dumbhippo/trunk/server/src/com/dumbhippo/server/impl/PostingBoardBean.java
   dumbhippo/trunk/server/src/com/dumbhippo/server/impl/StackerBean.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/BasicTests.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/InheritanceTests.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestUserDMO.java
Log:
DMInit DMClassHolder: Add @DMInit for group-specific initialization

DMClassHolder DMSession LazyInitializationException: Move initialization code
  from DMSession to generated code to allow for group-specific variants

TestUserDMO BasicTests InheritanceTests: Tests for group initialization

UserDMO: Use @DMInit to replace manually group initialization

ChangeNotificationSet ChangeNotification DataModel DMClassHolder: When
  creating a ChangeNotification, create it for the correct subclass.

PostDMO: Add link and date properties
BlockDMO: Add 'public' 'clickedTimestamp', 'ignoredTimestamp' properties

PostingBoardBean StackerBean: Notify clickedTimestamp/ignoredTimestamp 
  properties on changes

StackerBean: Fix a bug where clickedTimestamp was not set on the block
  when a block is clicked, because queryUserBlockData() wasn't handling
  data2Optional


Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotification.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotification.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotification.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -31,11 +31,10 @@
 	
 	private long[] feedTimestamps;
 
-	public ChangeNotification(Class<T> clazz, K key) {
-		this.clazz = clazz;
-		this.key = key;
-	}
-	
+	/**
+	 * DO NOT USE THIS CONSTRUCTOR DIRECTLY. Instead use model.makeChangeNotification(),
+	 * which properly handles subclassing. 
+	 */
 	public ChangeNotification(Class<T> clazz, K key, ClientMatcher matcher) {
 		this.clazz = clazz;
 		this.key = key;
@@ -145,6 +144,9 @@
 	
 	@Override
 	public String toString() {
-		return clazz.getSimpleName() + "#" + key.toString();
+		if (matcher != null)
+			return clazz.getSimpleName() + "#" + key.toString() + "; matcher=" + matcher;
+		else
+			return clazz.getSimpleName() + "#" + key.toString();
 	}
 }

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotificationSet.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotificationSet.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotificationSet.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -32,7 +32,7 @@
 	public ChangeNotificationSet(DataModel model) {
 	}
 
-	private <K, T extends DMObject<K>> ChangeNotification<K,T> getNotification(Class<T> clazz, K key, ClientMatcher matcher) {
+	private <K, T extends DMObject<K>> ChangeNotification<K,T> getNotification(DataModel model, Class<T> clazz, K key, ClientMatcher matcher) {
 		if (key instanceof DMKey) {
 			@SuppressWarnings("unchecked")
 			K clonedKey = (K)((DMKey)key).clone(); 
@@ -43,14 +43,14 @@
 			if (matchedNotifications == null)
 				matchedNotifications = new ArrayList<ChangeNotification<?,?>>();
 			
-			ChangeNotification<K,T> notification = new ChangeNotification<K,T>(clazz, key, matcher);
+			ChangeNotification<K,T> notification = model.makeChangeNotification(clazz, key, null);
 			matchedNotifications.add(notification);
 			return notification;
 		} else {
 			if (notifications == null)
 				notifications = new HashMap<ChangeNotification<?,?>, ChangeNotification<?,?>>();
 	
-			ChangeNotification<K,T> notification = new ChangeNotification<K,T>(clazz, key);
+			ChangeNotification<K,T> notification = model.makeChangeNotification(clazz, key, matcher);
 			@SuppressWarnings("unchecked")
 			ChangeNotification<K,T> oldNotification = (ChangeNotification<K,T>)notifications.get(notification);
 			if (oldNotification != null) {
@@ -64,12 +64,12 @@
 	}
 
 	public <K, T extends DMObject<K>> void changed(DataModel model, Class<T> clazz, K key, String propertyName, ClientMatcher matcher) {
-		ChangeNotification<K,T> notification = getNotification(clazz, key, matcher);
+		ChangeNotification<K,T> notification = getNotification(model, clazz, key, matcher);
 		notification.addProperty(model, propertyName);
 	}
 
 	public <K, T extends DMObject<K>> void feedChanged(DataModel model, Class<T> clazz, K key, String propertyName, long itemTimestamp) {
-		ChangeNotification<K,T> notification = getNotification(clazz, key, null);
+		ChangeNotification<K,T> notification = getNotification(model, clazz, key, null);
 		notification.addFeedProperty(model, propertyName, itemTimestamp);
 	}
 

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/DMSession.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/DMSession.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/DMSession.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -92,21 +92,6 @@
 		fetch.visit(this, object, visitor);
 	}
 
-	/**
-	 * For use in generated code; this isn't part of the public interface 
-	 * 
-	 * @param <T>
-	 * @param clazz
-	 * @param t
-	 */
-	public <K, T extends DMObject<K>> void internalInit(T t) {
-		try {
-			doInit(t);
-		} catch (NotFoundException e) {
-			throw new LazyInitializationException("NotFoundException when lazily initializing DMO; improper use of findUnchecked() or deleted object?", e);
-		}
-	}
-	
 	// This should return a StoreKey<K, ? extends T>, like classHolder.makeStoreKey(), but
 	// that confuses javac (Java 5) in ways I can't figure out. Practically speaking, it 
 	// doesn't end up mattering

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/DataModel.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/DataModel.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/DataModel.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -20,6 +20,7 @@
 import com.dumbhippo.dm.schema.DMClassHolder;
 import com.dumbhippo.dm.store.DMStore;
 import com.dumbhippo.dm.store.StoreClient;
+import com.dumbhippo.dm.store.StoreKey;
 
 /**
  * A DataModel is a central object holding information about entire data model; this 
@@ -246,6 +247,15 @@
 		return Timestamper.next();
 	}
 	
+	// This should return a ChangeNotiifcation<K, ? extends T>, like classHolder.makeChangeNotification(), but
+	// that confuses javac (Java 5) in ways I can't figure out. Practically speaking, it 
+	// doesn't end up mattering
+	@SuppressWarnings("unchecked")
+	protected <K, T extends DMObject<K>> ChangeNotification<K,T> makeChangeNotification(Class<T> clazz, K key, ClientMatcher matcher) {
+		DMClassHolder<K,T> classHolder = (DMClassHolder<K,T>)getClassHolder(clazz);
+		return (ChangeNotification<K, T>) classHolder.makeChangeNotification(key, matcher);
+	}
+
 	private void sendNotifications(ChangeNotificationSet notifications) {
 		logger.debug("Sending notifications for {}", notifications);
 		ClientNotificationSet clientNotifications = notifications.resolveNotifications(this);

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/LazyInitializationException.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/LazyInitializationException.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/LazyInitializationException.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -24,6 +24,11 @@
 			throw new RuntimeException("Cause must be specified");
 	}
 	
+	public LazyInitializationException(NotFoundException cause) {
+		this("NotFoundException when lazily initializing DMO; improper use of findUnchecked() or deleted object?", cause);
+	}
+	
+
 	@Override
 	public NotFoundException getCause() {
 		return (NotFoundException)super.getCause();

Added: dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/DMInit.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/DMInit.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/DMInit.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -0,0 +1,16 @@
+package com.dumbhippo.dm.annotations;
+
+/**
+ * Marks a method of a DMO class as an initialization function for a group of properties 
+ */
+public @interface DMInit {
+	/**
+	 * The group of properties that this initialization function is for.  
+	 */
+	int group();
+	
+	/**
+	 * Whether to call the main init() function before this initialization function.
+	 */
+	boolean initMain() default true;
+}

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMClassHolder.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMClassHolder.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMClassHolder.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -30,12 +30,15 @@
 
 import com.dumbhippo.GlobalSetup;
 import com.dumbhippo.dm.BadIdException;
+import com.dumbhippo.dm.ChangeNotification;
+import com.dumbhippo.dm.ClientMatcher;
 import com.dumbhippo.dm.DMInjectionLookup;
 import com.dumbhippo.dm.DMObject;
 import com.dumbhippo.dm.DMSession;
 import com.dumbhippo.dm.DMViewpoint;
 import com.dumbhippo.dm.DataModel;
 import com.dumbhippo.dm.annotations.DMFilter;
+import com.dumbhippo.dm.annotations.DMInit;
 import com.dumbhippo.dm.annotations.DMO;
 import com.dumbhippo.dm.annotations.Inject;
 import com.dumbhippo.dm.annotations.MetaConstruct;
@@ -65,6 +68,15 @@
 	private Constructor<? extends T> wrapperConstructor;
 	private DMPropertyHolder<K,T,?>[] properties;
 	private boolean[] mustQualify;
+
+	// Groups declared in this class (not parent classes)
+	private Map<Integer, PropertyGroup> groups = new HashMap<Integer, PropertyGroup>();
+	// Copies of parent groups (with the level set to the depth of the inheritance)
+	private List<PropertyGroup> parentGroups = new ArrayList<PropertyGroup>();
+	// Map from all properties relevant to this class (properties of this class and parent classes) 
+	// to the group for the property
+	private Map<DMPropertyHolder, PropertyGroup> groupsByProperty = new HashMap<DMPropertyHolder, PropertyGroup>();
+
 	private Map<String, Integer> propertiesMap = new HashMap<String, Integer>();
 	private Map<String, Integer> feedPropertiesMap = new HashMap<String, Integer>();
 	private DMO annotation;
@@ -316,6 +328,12 @@
 		return new StoreKey(subClassHolder, key);
 	}
 	
+	@SuppressWarnings("unchecked")
+	public ChangeNotification<K,? extends T> makeChangeNotification(K key, ClientMatcher matcher) {
+		DMClassHolder subClassHolder = getClassHolderForKey(key);
+		return new ChangeNotification(subClassHolder.getDMOClass(), key, matcher);
+	}
+	
 	public StoreKey<K,? extends T> makeStoreKey(String string) throws BadIdException {
 		
 		if (keyClass == Guid.class) {
@@ -462,6 +480,16 @@
 		}
 	}
 	
+	private PropertyGroup ensureGroup(int index) {
+		PropertyGroup group = groups.get(index);
+		if (!groups.containsKey(index)) {
+			group = new PropertyGroup(index);
+			groups.put(index, group);
+		}
+		
+		return group;
+	}
+	
 	private void collateProperties(CtClass baseCtClass) {
 		List<DMPropertyHolder<K,T,?>> foundProperties = new ArrayList<DMPropertyHolder<K,T,?>>();
 		Map<String, Integer> nameCount = new HashMap<String, Integer>();
@@ -474,10 +502,32 @@
 					nameCount.put(property.getName(), 1);
 				else
 					nameCount.put(property.getName(), 1 + nameCount.get(property.getName()));
+				
+				if (property.getGroup() >= 0) {
+					PropertyGroup group = ensureGroup(property.getGroup());
+					group.addProperty(property);
+					groupsByProperty.put(property, group);
+				}
+			} else {
+				Object[] annotations;
+				
+				try {
+					annotations = method.getAnnotations();
+				} catch (ClassNotFoundException e) {
+					throw new RuntimeException(dmoClass.getName() + ": Problem looking up annotations", e);
+				}
+				
+				for (Object annotation : annotations) {
+					if (annotation instanceof DMInit) {
+						DMInit init = (DMInit)annotation;
+						ensureGroup(init.group()).setInitMethod(method, init);
+					}
+				}
 			}
 		}
 		
 		Class<?> parentClass = dmoClass.getSuperclass();
+		int level = 1;
 		while (parentClass != null) {
 			DMO parentAnnotation = parentClass.getAnnotation(DMO.class);
 			if (parentAnnotation != null) {
@@ -497,9 +547,18 @@
 							nameCount.put(property.getName(), 1 + nameCount.get(property.getName()));
 					}
 				}
+				
+				for (Object o : parentClassHolder.groups.values()) {
+					PropertyGroup group = (PropertyGroup)o;
+					PropertyGroup groupCopy = new PropertyGroup(group, level); 
+					parentGroups.add(groupCopy);
+					for (DMPropertyHolder<K,T,?> property : group.getProperties())
+						groupsByProperty.put(property, groupCopy);
+				}
 			}
 			
 			parentClass = parentClass.getSuperclass();
+			level++;
 		}
 		
 		// Sort the properties based on the ordering we impose on DMPropertyHolder 
@@ -532,6 +591,10 @@
 		field.setModifiers(Modifier.PRIVATE);
 		wrapperCtClass.addField(field);
 		
+		field = new CtField(CtClass.booleanType, "_dm_injected", wrapperCtClass);
+		field.setModifiers(Modifier.PRIVATE);
+		wrapperCtClass.addField(field);
+
 		field = new CtField(CtClass.booleanType, "_dm_initialized", wrapperCtClass);
 		field.setModifiers(Modifier.PRIVATE);
 		wrapperCtClass.addField(field);
@@ -552,9 +615,17 @@
 		CtMethod method = new CtMethod(CtClass.voidType, "_dm_init", new CtClass[] {}, wrapperCtClass);
 		Template body = new Template(
 			"{" +
-			"    if (!_dm_initialized) {" +
-			"        _dm_session.internalInit($0);" +
-			"        _dm_initialized = true;" +
+			"  if (!_dm_initialized) {" +
+			"      if (!_dm_injected) {" +
+			"          _dm_classHolder.processInjections(_dm_session, this);" +
+			"          _dm_injected = true;" +
+			"      }" +
+			"      try {" +
+			"          init();" +
+			"      } catch (com.dumbhippo.server.NotFoundException e) {" +
+			"          throw new com.dumbhippo.dm.LazyInitializationException(e);" +
+			"      }" +
+			"      _dm_initialized = true;" +
 			"    }" +
 			"}");
 		method.setBody(body.toString());
@@ -568,14 +639,40 @@
 		wrapperCtClass.addMethod(method);
 	}
 	
-	private void addGroupInitMethod(CtClass wrapperCtClass, int group, List<DMPropertyHolder<K,T,?>> properties) throws CannotCompileException {
-		CtMethod method = new CtMethod(CtClass.voidType, "_dm_initGroup" + group, new CtClass[] {}, wrapperCtClass);
+	private void addGroupInitMethod(CtClass wrapperCtClass, PropertyGroup group) throws CannotCompileException {
+		String groupSuffix = group.getLevel() + "_" + group.getIndex();
+		
+		CtField field = new CtField(CtClass.booleanType, "_dm_initializedGroup" + groupSuffix, wrapperCtClass);
+		field.setModifiers(Modifier.PRIVATE);
+		wrapperCtClass.addField(field);
+		
+		CtMethod method = new CtMethod(CtClass.voidType, "_dm_initGroup" + groupSuffix, new CtClass[] {}, wrapperCtClass);
 		StringBuilder body = new StringBuilder();
 		
 		body.append("{" +
-					"  _dm_init();");
+					"   if (_dm_initializedGroup" + groupSuffix + ")" +
+					"       return;");
 		
-		for (DMPropertyHolder<K,T,?> property : properties) {
+		if (group.getInitMain())
+			body.append(
+					"   _dm_init();");
+		else
+			body.append(
+					"  if (!_dm_injected) {" +
+					"     _dm_classHolder.processInjections(_dm_session, this);" +
+					"     _dm_injected = true;" +
+					"  }");
+			
+		if (group.getInitMethod() != null) {
+			body.append(
+					"  try {" +
+					"     " + group.getInitMethod() + "();" +
+					"  } catch (com.dumbhippo.server.NotFoundException e) {" +
+					"      throw new com.dumbhippo.dm.LazyInitializationException(e);" +
+					"  }");
+		}
+		
+		for (DMPropertyHolder<K,T,?> property : group.getProperties()) {
 			Template propertyInit = new Template(
 					"  _dm_%propertyName% = %unboxPre%_dm_session.storeAndFilter(getStoreKey(), %propertyIndex%, %boxPre%super.%methodName%()%boxPost%)%unboxPost%;" +
 					"  _dm_%propertyName%Initialized = true;");
@@ -586,37 +683,25 @@
 			propertyInit.setParameter("unboxPre", property.getUnboxPrefix());
 			propertyInit.setParameter("unboxPost", property.getUnboxSuffix());
 			propertyInit.setParameter("propertyIndex", Integer.toString(getPropertyIndex(property.getName())));
-			propertyInit.setParameter("group", Integer.toString(group));
 			propertyInit.setParameter("methodName", property.getMethodName());
 
 			body.append(propertyInit.toString());
 		}
 		
-		body.append("}");
+		body.append(
+				"   _dm_initializedGroup" + groupSuffix + " = true;" +
+				"}");
 		
 		method.setBody(body.toString());
 		wrapperCtClass.addMethod(method);
 	}
 	
 	private void addGroupInitMethods(CtClass wrapperCtClass) throws CannotCompileException {
-		Map<Integer, List<DMPropertyHolder<K,T,?>>> map = new HashMap<Integer, List<DMPropertyHolder<K,T,?>>>();
+		for  (PropertyGroup group : groups.values()) 
+			addGroupInitMethod(wrapperCtClass, group);
 		
-		for (DMPropertyHolder<K,T,?> property : properties) {
-			int group = property.getGroup();
-			if (group >= 0) {
-				List<DMPropertyHolder<K,T,?>> l = map.get(group);
-				if (l == null) {
-					l = new ArrayList<DMPropertyHolder<K,T,?>>();
-					map.put(group, l);
-				}
-				
-				l.add(property);
-			}
-		}
-
-		for (int group : map.keySet()) {
-			addGroupInitMethod(wrapperCtClass, group, map.get(group));
-		}
+		for  (PropertyGroup group : parentGroups) 
+			addGroupInitMethod(wrapperCtClass, group);
 	}
 
 	private void addGetClassHolderMethod(CtClass wrapperCtClass) throws CannotCompileException {
@@ -663,15 +748,16 @@
 		CtMethod wrapperMethod = new CtMethod(property.getCtClass(), property.getMethodName(), new CtClass[] {}, wrapperCtClass);
 		
 		String storeCommands;
-		int group = property.getGroup();
-		if (group < 0) {
+		PropertyGroup group = groupsByProperty.get(property); 
+		
+		if (group == null) {
 			storeCommands =
 				"_dm_init();" +
 				"_dm_%propertyName% = %unboxPre%_dm_session.storeAndFilter(getStoreKey(), %propertyIndex%, %boxPre%super.%methodName%()%boxPost%)%unboxPost%;" +
 				"_dm_%propertyName%Initialized = true;";
 		} else {
 			storeCommands = 
-				"_dm_initGroup%group%();";
+				"_dm_initGroup%groupSuffix%();";
 		}
 		
 		Template body = new Template(
@@ -693,7 +779,7 @@
 		body.setParameter("unboxPre", property.getUnboxPrefix());
 		body.setParameter("unboxPost", property.getUnboxSuffix());
 		body.setParameter("propertyIndex", Integer.toString(propertyIndex));
-		body.setParameter("group", Integer.toString(group));
+		body.setParameter("groupSuffix", group != null ? (group.getLevel() + "_" + group.getIndex()) : "~~~");
 		body.setParameter("methodName", property.getMethodName());
 		wrapperMethod.setBody(body.toString());
 		
@@ -748,12 +834,12 @@
 		CtClass wrapperCtClass = classPool.makeClass(className + "_DMWrapper", baseCtClass);
 		
 		try {
+			addGetClassHolderMethod(wrapperCtClass);
 			addCommonFields(wrapperCtClass);
 			addPropertyFields(wrapperCtClass);
 			addConstructor(wrapperCtClass);
 			addInitMethod(baseCtClass, wrapperCtClass);
 			addGroupInitMethods(wrapperCtClass);
-			addGetClassHolderMethod(wrapperCtClass);
 			addWrapperGetters(wrapperCtClass);
 			
 			Class<?> wrapperClass  = wrapperCtClass.toClass();
@@ -778,4 +864,65 @@
 	public static DMClassHolder<?, ? extends DMObject<?>> createForClass(DataModel model, Class<?> clazz) {
 		return newClassHolderHack(model, DMClassInfo.getForClass(clazz));
 	}
+	
+	private class PropertyGroup {
+		public List<DMPropertyHolder<K,T,?>> properties = new ArrayList<DMPropertyHolder<K,T,?>>();
+		public boolean initMain = true;
+		public String initMethod = null;
+		int index;
+		int level;
+		
+		public PropertyGroup(int index) {
+			this.index = index;
+			this.level = 0;
+		}
+		
+		/* This is used to make a copy of a group in a parent class */
+		public PropertyGroup(PropertyGroup other, int level) {
+			this.properties = other.properties;
+			this.initMain = other.initMain;
+			this.initMethod = other.initMethod;
+			this.index = other.index;
+			this.level = level;
+		}
+		
+		public void addProperty(DMPropertyHolder<K,T,?> property) {
+			properties.add(property);
+		}
+		
+		public void setInitMethod(CtMethod method, DMInit annotation) {
+			try {
+				if (method.getParameterTypes().length != 0)
+					throw new RuntimeException(dmoClass.getName() + ": @DMInit method cannot have parameters");
+			} catch (NotFoundException e) {
+				throw new RuntimeException(dmoClass.getName() + ": Problem looking up parameters for @DMInit method", e);
+			}
+			
+			if (initMethod != null)
+				throw new RuntimeException(dmoClass.getName() + ": Cannot add DMInit method " + method.getName() + " for group " + index + " already have method " + initMethod);
+			
+			initMethod = method.getName();
+			initMain = annotation.initMain();
+		}
+
+		public int getIndex() {
+			return index;
+		}
+		
+		public int getLevel() {
+			return level;
+		}
+
+		public boolean getInitMain() {
+			return initMain;
+		}
+
+		public String getInitMethod() {
+			return initMethod;
+		}
+
+		public List<DMPropertyHolder<K, T, ?>> getProperties() {
+			return properties;
+		}
+	}
 }

Modified: dumbhippo/trunk/server/src/com/dumbhippo/server/dm/BlockDMO.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/server/dm/BlockDMO.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/server/dm/BlockDMO.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -8,6 +8,7 @@
 import com.dumbhippo.dm.DMObject;
 import com.dumbhippo.dm.DMSession;
 import com.dumbhippo.dm.annotations.DMFilter;
+import com.dumbhippo.dm.annotations.DMInit;
 import com.dumbhippo.dm.annotations.DMO;
 import com.dumbhippo.dm.annotations.DMProperty;
 import com.dumbhippo.dm.annotations.Inject;
@@ -15,22 +16,32 @@
 import com.dumbhippo.dm.store.StoreKey;
 import com.dumbhippo.identity20.Guid;
 import com.dumbhippo.persistence.BlockType;
+import com.dumbhippo.persistence.UserBlockData;
+import com.dumbhippo.persistence.BlockType.BlockVisibility;
 import com.dumbhippo.server.NotFoundException;
 import com.dumbhippo.server.Stacker;
 import com.dumbhippo.server.blocks.BlockView;
 import com.dumbhippo.server.blocks.TitleBlockView;
 import com.dumbhippo.server.blocks.TitleDescriptionBlockView;
 import com.dumbhippo.server.views.SystemViewpoint;
+import com.dumbhippo.server.views.UserViewpoint;
+import com.dumbhippo.server.views.Viewpoint;
 
 @DMO(classId="http://mugshot.org/p/o/block";, resourceBase="/o/block")
 @DMFilter("viewer.canSeeBlock(this)")
 public abstract class BlockDMO extends DMObject<BlockDMOKey> {
 	@SuppressWarnings("unused")
 	private static Logger logger = GlobalSetup.getLogger(BlockDMO.class);
+	
+	static final int USER_BLOCK_DATA_GROUP = 1;
 
 	protected BlockView blockView;
+	private UserBlockData userBlockData;
 
 	@Inject
+	Viewpoint viewpoint;
+	
+	@Inject
 	DMSession session;
 	
 	@EJB
@@ -115,8 +126,38 @@
 			return null;
 	}
 	
+	@DMProperty(defaultInclude=true)
+	public boolean isPublic() {
+		return blockView.getBlockType().getBlockVisibility() == BlockVisibility.PUBLIC;
+	}
+	
 	//////////////////////////////////////////////////////////////////////
 	
+	@DMInit(group=USER_BLOCK_DATA_GROUP, initMain=false) 
+	public void initUserBlockData() {
+		if (viewpoint instanceof UserViewpoint) {
+			try {
+				userBlockData = stacker.lookupUserBlockData((UserViewpoint)viewpoint, getKey().getBlockId());
+			} catch (NotFoundException e) {
+			}
+		}
+	}
+	
+	@DMProperty(defaultInclude=true, group=USER_BLOCK_DATA_GROUP, cached=false)
+	public long getClickedTimestamp() {
+		return userBlockData.getClickedTimestampAsLong();
+	}
+
+	@DMProperty(defaultInclude=true, group=USER_BLOCK_DATA_GROUP, cached=false)
+	public long getIgnoredTimestamp() {
+		if (userBlockData.isIgnored())
+			return userBlockData.getIgnoredTimestampAsLong();
+		else
+			return -1;
+	}
+	
+	//////////////////////////////////////////////////////////////////////
+	
 	// These properties are here for the implementation of Viewpoint.canSeePrivateBlock(), 
 	// Viewpoint.canSeeBlockDelegate()
 	

Modified: dumbhippo/trunk/server/src/com/dumbhippo/server/dm/PostDMO.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/server/dm/PostDMO.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/server/dm/PostDMO.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -13,6 +13,7 @@
 import com.dumbhippo.dm.annotations.DMO;
 import com.dumbhippo.dm.annotations.DMProperty;
 import com.dumbhippo.dm.annotations.Inject;
+import com.dumbhippo.dm.annotations.PropertyType;
 import com.dumbhippo.identity20.Guid;
 import com.dumbhippo.persistence.AccountClaim;
 import com.dumbhippo.persistence.Post;
@@ -22,6 +23,7 @@
 import com.dumbhippo.server.NotFoundException;
 import com.dumbhippo.server.PostingBoard;
 import com.dumbhippo.server.views.SystemViewpoint;
+import com.dumbhippo.server.views.Viewpoint;
 
 @DMO(classId="http://mugshot.org/p/o/post";, resourceBase="/o/post")
 @DMFilter("viewer.canSeePost(this)")
@@ -32,6 +34,9 @@
 	@Inject
 	DMSession session;
 
+	@Inject
+	Viewpoint viewpoint;
+
 	private Post post;
 	
 	protected PostDMO(Guid key) {
@@ -62,7 +67,17 @@
 		return post.getText();
 	}
 	
+	@DMProperty(defaultInclude=true, type=PropertyType.URL)
+	public String getLink() {
+		return post.getUrl().toString();
+	}
+	
 	@DMProperty(defaultInclude=true)
+	public long getDate() {
+		return post.getPostDate().getTime();
+	}
+
+	@DMProperty(defaultInclude=true)
 	@DMFilter("viewer.canSeePrivate(any)")
 	public List<UserDMO> getUserRecipients() {
 		List<UserDMO> result = new ArrayList<UserDMO>();

Modified: dumbhippo/trunk/server/src/com/dumbhippo/server/dm/UserDMO.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/server/dm/UserDMO.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/server/dm/UserDMO.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -21,6 +21,7 @@
 import com.dumbhippo.dm.DMObject;
 import com.dumbhippo.dm.DMSession;
 import com.dumbhippo.dm.annotations.DMFilter;
+import com.dumbhippo.dm.annotations.DMInit;
 import com.dumbhippo.dm.annotations.DMO;
 import com.dumbhippo.dm.annotations.DMProperty;
 import com.dumbhippo.dm.annotations.Inject;
@@ -63,7 +64,6 @@
 	private static final int CURRENT_TRACK_GROUP = 2; 
 	
 	private TrackHistory currentTrack;
-	private boolean currentTrackFetched;
 
 	// people who have any opinion of this UserDMO except blocked
 	private Set<UserDMO> contacters; /* Includes hot/medium/cold contacters */
@@ -161,37 +161,36 @@
 		return result;
 	}
 	
-	private void ensureContacters() {
-		if (contacters == null) {
-			blockingContacters = new HashSet<UserDMO>();
-			contacters = new HashSet<UserDMO>();
-			coldContacters = new HashSet<UserDMO>();
-			hotContacters = new HashSet<UserDMO>();
+	@DMInit(group=CONTACTERS_GROUP, initMain=false)
+	public void initContacters() {
+		blockingContacters = new HashSet<UserDMO>();
+		contacters = new HashSet<UserDMO>();
+		coldContacters = new HashSet<UserDMO>();
+		hotContacters = new HashSet<UserDMO>();
 
-			for (Pair<Guid,ContactStatus> pair : identitySpider.computeContactersWithStatus(user.getGuid())) {
-				Guid guid = pair.getFirst();
-				ContactStatus status = pair.getSecond();
-				
-				UserDMO contacter = session.findUnchecked(UserDMO.class, guid);
+		for (Pair<Guid,ContactStatus> pair : identitySpider.computeContactersWithStatus(getKey())) {
+			Guid guid = pair.getFirst();
+			ContactStatus status = pair.getSecond();
 			
-				switch (status) {
-				case NONCONTACT:
-					break;
-				case BLOCKED:
-					blockingContacters.add(contacter);
-					break;
-				case COLD:
-					coldContacters.add(contacter);
-					contacters.add(contacter);
-					break;
-				case MEDIUM:
-					contacters.add(contacter);
-					break;
-				case HOT:
-					hotContacters.add(contacter);
-					contacters.add(contacter);
-					break;
-				}
+			UserDMO contacter = session.findUnchecked(UserDMO.class, guid);
+		
+			switch (status) {
+			case NONCONTACT:
+				break;
+			case BLOCKED:
+				blockingContacters.add(contacter);
+				break;
+			case COLD:
+				coldContacters.add(contacter);
+				contacters.add(contacter);
+				break;
+			case MEDIUM:
+				contacters.add(contacter);
+				break;
+			case HOT:
+				hotContacters.add(contacter);
+				contacters.add(contacter);
+				break;
 			}
 		}
 	}
@@ -199,8 +198,6 @@
 	@DMProperty(group=CONTACTERS_GROUP)
 	@DMFilter("viewer.canSeePrivate(this)")
 	public Set<UserDMO> getContacters() {
-		ensureContacters();
-		
 		return contacters;
 	}
 	
@@ -212,24 +209,18 @@
 	@DMProperty(group=CONTACTERS_GROUP)
 	@DMFilter("false")
 	public Set<UserDMO> getBlockingContacters() {
-		ensureContacters();
-		
 		return blockingContacters;
 	}
 	
 	@DMProperty(group=CONTACTERS_GROUP)
 	@DMFilter("false")
 	public Set<UserDMO> getColdContacters() {
-		ensureContacters();
-		
 		return coldContacters;
 	}
 
 	@DMProperty(group=CONTACTERS_GROUP)
 	@DMFilter("false")
 	public Set<UserDMO> getHotContacters() {
-		ensureContacters();
-		
 		return hotContacters;
 	}
 	
@@ -429,34 +420,30 @@
 		return result;
 	}
 	
-	private void ensureCurrentTrack() {
-		if (!currentTrackFetched) {
-			try {
-				currentTrack = musicSystem.getCurrentTrack(AnonymousViewpoint.getInstance(Site.NONE), user);
-				int duration = currentTrack.getTrack().getDuration();
-				
-				// A negative duration means "unknown". We also treat durations of over an hour as suspect,
-				// and substitute a default duration for determining if the track is still playing
-				if (duration <= 0 || duration > 60 * 60) {
-					duration = 30 * 60;
-				}
-				
-				// While we don't try to notify when tracks stop playing, we want to omit past tracks
-				// to avoid doing web-services work to get the details of "current" tracks that were
-				// played months ago.
-				if (currentTrack.getLastUpdated().getTime() + duration * 1000L <  System.currentTimeMillis())
-					currentTrack = null;
-
-			} catch (NotFoundException e) {
+	@DMInit(group=CURRENT_TRACK_GROUP)
+	public void initCurrentTrack() {
+		try {
+			currentTrack = musicSystem.getCurrentTrack(AnonymousViewpoint.getInstance(Site.NONE), user);
+			int duration = currentTrack.getTrack().getDuration();
+			
+			// A negative duration means "unknown". We also treat durations of over an hour as suspect,
+			// and substitute a default duration for determining if the track is still playing
+			if (duration <= 0 || duration > 60 * 60) {
+				duration = 30 * 60;
 			}
-			currentTrackFetched = true;
+			
+			// While we don't try to notify when tracks stop playing, we want to omit past tracks
+			// to avoid doing web-services work to get the details of "current" tracks that were
+			// played months ago.
+			if (currentTrack.getLastUpdated().getTime() + duration * 1000L <  System.currentTimeMillis())
+				currentTrack = null;
+
+		} catch (NotFoundException e) {
 		}
 	}
 	
 	@DMProperty(group=CURRENT_TRACK_GROUP)
 	public TrackDMO getCurrentTrack() {
-		ensureCurrentTrack();
-		
 		if (currentTrack != null)
 			return session.findUnchecked(TrackDMO.class, currentTrack.getTrack().getId());
 		else
@@ -465,8 +452,6 @@
 	
 	@DMProperty(group=CURRENT_TRACK_GROUP)
 	public long getCurrentTrackPlayTime() {
-		ensureCurrentTrack();
-		
 		if (currentTrack != null)
 			return currentTrack.getLastUpdated().getTime();
 		else

Modified: dumbhippo/trunk/server/src/com/dumbhippo/server/impl/PostingBoardBean.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/server/impl/PostingBoardBean.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/server/impl/PostingBoardBean.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -71,6 +71,7 @@
 import com.dumbhippo.server.PostingBoard;
 import com.dumbhippo.server.RecommenderSystem;
 import com.dumbhippo.server.blocks.PostBlockHandler;
+import com.dumbhippo.server.dm.DataService;
 import com.dumbhippo.server.util.EJBUtil;
 import com.dumbhippo.server.util.GuidNotFoundException;
 import com.dumbhippo.server.views.AnonymousViewpoint;
@@ -752,6 +753,8 @@
 		// to write our own code for asynchronous execution
 		TxUtils.runInTransactionOnCommit(new TxRunnable() {
 			public void run() throws RetryException {
+				DataService.getModel().initializeReadWriteSession(new UserViewpoint(viewpoint.getViewerId(), viewpoint.getSite()));
+				
 				Post attachedPost = em.find(Post.class, post.getId());
 				User attachedUser = em.find(User.class, viewpoint.getViewer().getId());
 

Modified: dumbhippo/trunk/server/src/com/dumbhippo/server/impl/StackerBean.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/server/impl/StackerBean.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/src/com/dumbhippo/server/impl/StackerBean.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -89,7 +89,10 @@
 import com.dumbhippo.server.blocks.RedditBlockHandler;
 import com.dumbhippo.server.blocks.TwitterPersonBlockHandler;
 import com.dumbhippo.server.blocks.YouTubeBlockHandler;
+import com.dumbhippo.server.dm.BlockDMO;
+import com.dumbhippo.server.dm.BlockDMOKey;
 import com.dumbhippo.server.dm.DataService;
+import com.dumbhippo.server.dm.UserClientMatcher;
 import com.dumbhippo.server.dm.UserDMO;
 import com.dumbhippo.server.util.EJBUtil;
 import com.dumbhippo.server.views.GroupMugshotView;
@@ -321,34 +324,13 @@
 	}
 	
 	private UserBlockData queryUserBlockData(User user, BlockKey key) throws NotFoundException {
-		Guid data1 = key.getData1();
-		Guid data2 = key.getData2();
-		long data3 = key.getData3();
-		StackInclusion inclusion = key.getInclusion();
-
-		Query q;
-		if (data1 != null && data2 != null) {
-			q = em.createQuery("SELECT ubd FROM UserBlockData ubd, Block block WHERE ubd.block = block AND ubd.user = :user AND block.blockType=:type AND block.data1=:data1 AND block.data2=:data2 AND block.data3=:data3 " +
-					"AND block.inclusion = :inclusion");
-			q.setParameter("data1", data1.toString());
-			q.setParameter("data2", data2.toString());
-		} else if (data1 != null) {
-			q = em.createQuery("SELECT ubd FROM UserBlockData ubd, Block block WHERE ubd.block = block AND ubd.user = :user AND block.blockType=:type AND block.data1=:data1 AND block.data2='' AND block.data3=:data3 " +
-					"AND block.inclusion = :inclusion");
-			q.setParameter("data1", data1.toString());
-		} else if (data2 != null) {
-			q = em.createQuery("SELECT ubd FROM UserBlockData ubd, Block block WHERE ubd.block = block AND ubd.user = :user AND block.blockType=:type AND block.data2=:data2 AND block.data1='' AND block.data3=:data3 " +
-					"AND block.inclusion = :inclusion");
-			q.setParameter("data2", data2);
-		} else {
-			throw new IllegalArgumentException("must provide either data1 or data2 in query for block  " + key);
-		}
-		q.setParameter("data3", data3);
+		Query q = em.createQuery(
+				"SELECT ubd FROM UserBlockData ubd, Block block" +
+				" WHERE ubd.block = block" +
+				"   AND ubd.user = :user " +
+				"   AND " + getBlockClause(key));
+		setBlockParameters(key, q);
 		q.setParameter("user", user);
-		q.setParameter("type", key.getBlockType());
-		if (inclusion == null)
-			throw new IllegalArgumentException("missing inclusion in key " + key);
-		q.setParameter("inclusion", inclusion);
 		try {
 			return (UserBlockData) q.getSingleResult();
 		} catch (NoResultException e) {
@@ -794,6 +776,13 @@
 		blockClicked(ubd, clickedTime);
 	}
 		
+	private void invalidateUserBlockDataProperty(UserBlockData ubd, String propertyName) {
+		DataService.currentSessionRW().changed(BlockDMO.class,
+				   new BlockDMOKey(ubd.getBlock()),
+				   propertyName,
+				   new UserClientMatcher(ubd.getUser().getGuid()));
+	}
+	
 	public void blockClicked(UserBlockData ubd, long clickedTime) {
 		// if we weren't previously clicked on, then increment the count.
 		// (FIXME this is not a reliable way of incrementing a count, since two transactions
@@ -801,19 +790,21 @@
 		if (!ubd.isClicked())
 			ubd.getBlock().setClickedCount(ubd.getBlock().getClickedCount() + 1);
 		
-		if (ubd.getClickedTimestampAsLong() < clickedTime)
+		if (ubd.getClickedTimestampAsLong() < clickedTime) {
 			ubd.setClickedTimestampAsLong(clickedTime);
+			invalidateUserBlockDataProperty(ubd, "clickedTimestamp");
+		}
 		
 		// we automatically unignore anything you click on
 		if (ubd.isIgnored())
 			setBlockHushed(ubd, false);
 		
-		logger.debug("due to click, restacking block {} with new time {}",
-				ubd.getBlock(), clickedTime);
-		
 		if (!BlockView.clickedCountIsSignificant(ubd.getBlock().getClickedCount()))
 			return;
 		
+		logger.debug("due to click, restacking block {} with new time {}",
+				ubd.getBlock(), clickedTime);
+		
 		// now update the timestamp in the block (if it's newer)
 		// and update user caches for all users
 		stack(ubd.getBlock(), clickedTime, StackReason.VIEWER_COUNT);
@@ -1632,10 +1623,12 @@
 	public void setBlockHushed(UserBlockData userBlockData, boolean hushed) {
  		if (hushed != userBlockData.isIgnored()) {
 	 		userBlockData.setIgnored(hushed);
-	 		if (hushed)
+	 		if (hushed) {
 	 			userBlockData.setIgnoredTimestampAsLong(userBlockData.getBlock().getTimestampAsLong());
-	 		else
+	 		} else
 	 			userBlockData.setStackTimestampAsLong(userBlockData.getBlock().getTimestampAsLong());
+	 		
+			invalidateUserBlockDataProperty(userBlockData, "ignoredTimestamp");
  		}
 	}
 

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/BasicTests.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/BasicTests.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/BasicTests.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -58,7 +58,7 @@
 		assertTrue(groupDMO != null);
 		assertTrue(groupDMO.getKey().equals(guid));
 		assertTrue(groupDMO.getName().equals("Hippos"));
-
+		
 		em.getTransaction().commit();
 		
 		//////////////////////////////////////////////////
@@ -136,6 +136,38 @@
 		em.getTransaction().commit();
 	}
 	
+	// Test grouping
+	public void testGrouping() throws Exception {
+		EntityManager em;
+
+		TestViewpoint viewpoint = new TestViewpoint(Guid.createNew());
+		
+		/////////////////////////////////////////////////
+		// Setup
+
+		em = support.beginSessionRW(viewpoint);
+
+		TestUser bob = new TestUser("Bob");
+		Guid bobId = bob.getGuid();
+		em.persist(bob);
+		
+
+		em.getTransaction().commit();
+
+		/////////////////////////////////////////////////
+
+		em = support.beginSessionRO(viewpoint);
+
+		ReadOnlySession session = support.currentSessionRO();
+
+		TestUserDMO bobDMO = session.find(TestUserDMO.class, bobId);
+		
+		assertEquals("initializedA", bobDMO.getGroupedA());
+		assertEquals("initializedB", bobDMO.getGroupedB());
+
+		em.getTransaction().commit();
+	}
+	
 	// Test looking up objects by String resource ID
 	public void testStringResourceId() throws NotFoundException {
 		EntityManager em;

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/InheritanceTests.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/InheritanceTests.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/InheritanceTests.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -43,6 +43,10 @@
 		assertEquals("*The Nose*", superUserDMO.getName());
 		assertEquals("The ability to tell if leftovers have gone bad", superUserDMO.getSuperPower());
 
+		/// Test that groups in base classes are handled in inherited classes
+		assertEquals("initializedA", superUserDMO.getGroupedA());
+		assertEquals("initializedB", superUserDMO.getGroupedB());
+
 		em.getTransaction().commit();
 	}
 }

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestUserDMO.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestUserDMO.java	2007-12-13 01:40:12 UTC (rev 7049)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestUserDMO.java	2007-12-13 17:49:05 UTC (rev 7050)
@@ -18,6 +18,7 @@
 import com.dumbhippo.dm.DMObject;
 import com.dumbhippo.dm.DMSession;
 import com.dumbhippo.dm.annotations.DMFilter;
+import com.dumbhippo.dm.annotations.DMInit;
 import com.dumbhippo.dm.annotations.DMO;
 import com.dumbhippo.dm.annotations.DMProperty;
 import com.dumbhippo.dm.annotations.Inject;
@@ -76,6 +77,29 @@
 		return new BlogEntryFeed();
 	}
 	
+	//////////////////////////////////////////
+	
+	// Very basic test of grouping
+	
+	private String group1Value;
+	
+	@DMInit(group=1, initMain=false)
+	public void initGroup1() {
+		group1Value = "initialized";
+	}
+	
+	@DMProperty(group=1)
+	public String getGroupedA() {
+		return group1Value + "A";
+	}
+	
+	@DMProperty(group=1)
+	public String getGroupedB() {
+		return group1Value + "B";
+	}
+
+	//////////////////////////////////////////
+	
 	private class BlogEntryFeed implements DMFeed<TestBlogEntryDMO> {
 		public Iterator<DMFeedItem<TestBlogEntryDMO>> iterator(int start, int max, long minTimestamp) {
 			logger.debug("Querying blog entries from database, start={}, max={}, minTimestamp={}",



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]