diff src/nabble/model/NodeImpl.java @ 0:7ecd1a4ef557

add content
author Franklin Schmidt <fschmidt@gmail.com>
date Thu, 21 Mar 2019 19:15:52 -0600
parents
children 72765b66e2c3
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/nabble/model/NodeImpl.java	Thu Mar 21 19:15:52 2019 -0600
@@ -0,0 +1,2347 @@
+package nabble.model;
+
+import fschmidt.db.DbDatabase;
+import fschmidt.db.DbNull;
+import fschmidt.db.DbObjectFactory;
+import fschmidt.db.DbRecord;
+import fschmidt.db.DbTable;
+import fschmidt.db.DbUtils;
+import fschmidt.db.Listener;
+import fschmidt.db.ListenerList;
+import fschmidt.db.LongKey;
+import fschmidt.html.Html;
+import fschmidt.util.java.Computable;
+import fschmidt.util.java.Filter;
+import fschmidt.util.java.HtmlUtils;
+import fschmidt.util.java.Memoizer;
+import fschmidt.util.java.ObjectUtils;
+import fschmidt.util.java.SimpleCache;
+import nabble.model.export.Export;
+import nabble.model.export.NodeData;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.net.InetAddress;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.text.CollationKey;
+import java.text.Collator;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+
+
+final class NodeImpl implements Node, Node.MailFromList {
+	private static final Logger logger = LoggerFactory.getLogger(NodeImpl.class);
+
+	private static final Message DELETED_MESSAGE = new Message("- deleted -", Message.Format.TEXT) {
+		public boolean isDeleted() { return true; }
+		public boolean isDeactivated() { return true; }
+	};
+
+	final SiteKey siteKey;
+	private final DbRecord<LongKey,NodeImpl> record;
+	private String subject;
+	private MyMessage message;
+	private Date whenCreated;
+	private long ownerId;
+	private UserImpl owner;
+	private long parentId = 0L;
+	private NodeImpl parent;
+	private String messageID;
+	private Date whenUpdated;
+	private Boolean isGuessedParent = false;
+	private Date whenSent;
+	private long lastNodeId;
+	private Date lastNodeDate;
+	private int nodeCount;
+	private Kind kind;
+	private String type;
+	private boolean isPinned;
+	private int childCount;
+	private String cookie;
+	private String anonymousName;
+	private long exportedNodeId;
+	private String exportPermalink = null;
+	private String exportEmail = null;
+	private String embeddingUrl;
+
+	private NodeImpl(SiteKey siteKey,LongKey key,ResultSet rs)
+			throws SQLException
+	{
+		this.siteKey = siteKey;
+		record = table(siteKey).newRecord(this,key);
+		subject = rs.getString("subject");
+		Message.Format msgFmt = Message.Format.getMessageFormat( rs.getString("msg_fmt").charAt(0) );
+		message = new MyMessage(msgFmt);
+		whenCreated = rs.getTimestamp("when_created");
+		ownerId = rs.getLong("owner_id");
+		parentId = rs.getLong("parent_id");
+		messageID = rs.getString("message_id");
+		whenUpdated = rs.getTimestamp("when_updated");
+		isGuessedParent = rs.getBoolean("guessed_parent");
+		if (rs.wasNull()) isGuessedParent = null;
+		whenSent = rs.getTimestamp("when_sent");
+		lastNodeId = rs.getLong("last_node_id");
+		lastNodeDate = rs.getTimestamp("last_node_date");
+		nodeCount = rs.getInt("node_count");
+		kind = rs.getBoolean("is_app") ? Kind.APP : Kind.POST;
+		type = rs.getString("type");
+		if( type==null )
+			type = Type.COMMENT;
+		rs.getInt("pin");
+		isPinned = !rs.wasNull();
+		childCount = rs.getInt("child_count");
+		cookie = rs.getString("cookie");
+		anonymousName = rs.getString("anonymous_name");
+		embeddingUrl = rs.getString("embedding_url");
+		exportedNodeId = rs.getLong("exported_node_id");
+		exportPermalink = rs.getString("export_permalink");
+		exportEmail = rs.getString("export_email");
+		for( ExtensionFactory<Node,?> factory : extensionFactories ) {
+			Object obj = factory.construct(this,rs);
+			if( obj != null )
+				getExtensionMap().put(factory,obj);
+		}
+	}
+
+	static NodeImpl newRootNode(Kind kind,UserImpl owner,String subject,String messageRaw,Message.Format msgFmt)
+		throws ModelException
+	{
+		SiteImpl site = owner.getSiteImpl();
+		return new NodeImpl(site.siteKey,kind,owner,subject,messageRaw,msgFmt);
+	}
+
+	static NodeImpl newRootNode(Kind kind,Person owner,String subject,String messageRaw,Message.Format msgFmt,SiteImpl site,String type)
+		throws ModelException
+	{
+		NodeImpl newRoot = new NodeImpl(site.siteKey,kind,owner,subject,messageRaw,msgFmt);
+		if( type != null )
+			newRoot.setType(type);
+		newRoot.insert(true);
+		NodeImpl oldRoot = site.getRootNodeImpl();
+		oldRoot.parentId = newRoot.getId();
+		oldRoot.record.fields().put("parent_id", oldRoot.parentId);
+		oldRoot.update();
+		site.setRoot(newRoot);
+		site.getDbRecord().update();
+		oldRoot.addNode();
+		return newRoot;
+	}
+
+	static NodeImpl newChildNode(Kind kind,Person owner,String subject,String messageRaw,Message.Format msgFmt,NodeImpl parent)
+		throws ModelException
+	{
+		SiteImpl site = parent.getSiteImpl();
+		if( !site.equals(owner.getSite()) )
+			throw new RuntimeException();
+		NodeImpl node = new NodeImpl(site.siteKey,kind,owner,subject,messageRaw,msgFmt);
+		node.setParent(parent);
+		return node;
+	}
+
+	private NodeImpl(SiteKey siteKey,Kind kind,Person owner,String subject,String messageRaw,Message.Format msgFmt)
+		throws ModelException
+	{
+		this.siteKey = siteKey;
+		record = table(siteKey).newRecord(this);
+		if( owner instanceof UserImpl ) {
+			this.owner = (UserImpl)owner;
+			ownerId = this.owner.getId();
+			record.fields().put("owner_id", ownerId);
+		} else {
+			Anonymous anon = (Anonymous)owner;
+			this.cookie = anon.getCookie();
+			this.anonymousName = anon.getName();
+			record.fields().put("cookie",cookie);
+			record.fields().put("anonymous_name", anonymousName);
+		}
+		setKind(kind);
+		setSubject0(subject);
+		message = new MyMessage(msgFmt);
+		setMessage0(messageRaw,msgFmt);
+		setWhenCreated(new Date());
+		nodeCount = 1;
+		childCount = 0;
+	}
+
+	private void setParent(NodeImpl parent)
+		throws ModelException
+	{
+		if( isInDb() )
+			throw new RuntimeException();
+		this.parentId = parent.getId();
+		record.fields().put("parent_id", parentId);
+		if( siteKey != parent.siteKey )
+			throw new RuntimeException();
+		if( !parent.isInDb() ) {
+			// hack for dummy nodes
+			this.parent = parent;
+			List<NodeImpl> children = dummyChildMap.get(parent);
+			if( children == null ) {
+				children = new ArrayList<NodeImpl>();
+				dummyChildMap.put(parent,children);
+			}
+			children.add(this);
+			return;
+		}
+		if( db().isInTransaction() )
+			uncacheAncestors();
+	}
+
+	public DbRecord<LongKey,NodeImpl> getDbRecord() {
+		return record;
+	}
+
+	private DbTable<LongKey,NodeImpl> table() {
+		return record.getDbTable();
+	}
+
+	private DbDatabase db() {
+		return table().getDbDatabase();
+	}
+
+	public long getId() {
+		LongKey key = record.getPrimaryKey();
+		if( key==null )
+			return 0L;
+		return key.value();
+	}
+
+	public Kind getKind() {
+		return kind;
+	}
+
+	public void setKind(Kind kind) {
+		this.kind = kind;
+		record.fields().put( "is_app", DbNull.fix(kind==Kind.APP) );
+	}
+
+	long getParentId() {
+		return parentId;
+	}
+
+	long getOwnerId() {
+		return ownerId;
+	}
+
+	public String getSubject() {
+		return subject;
+	}
+
+	public String getSubjectHtml() {
+		return HtmlUtils.htmlEncode(subject);
+	}
+
+	private synchronized boolean setSubject0(String subject) throws ModelException.RequiredSubject {
+		subject = subject.trim();
+		if( subject.equals("") )
+			throw new ModelException.RequiredSubject();
+		if( subject.equals(this.subject) )
+			return false;
+		this.subject = subject;
+		record.fields().put("subject",subject);
+		return true;
+	}
+
+	public void setSubject(String subject) throws ModelException.RequiredSubject {
+		boolean changed = setSubject0(subject);
+		if (changed)
+			setWhenUpdated();
+	}
+
+	private static final long TEN_MINUTES = 10 * 60 * 1000;
+
+	private void setWhenUpdated() {
+		long timeDiff = new Date().getTime() - getWhenCreated().getTime();
+		boolean acceptChanges = timeDiff > TEN_MINUTES || !getChildren().isEmpty() || isMailToList();
+		if (acceptChanges) {
+			setWhenUpdated( new Date() );
+		}
+	}
+
+
+	private final class MyMessage extends Message {
+
+		MyMessage(Message.Format format) {
+			super(null,format);
+		}
+
+		MyMessage(String raw,Message.Format format) {
+			super(raw,format);
+		}
+
+		private synchronized boolean hasLoaded() {
+			return raw!=null;
+		}
+
+		public synchronized String getRaw() {
+			if( raw==null && record.isInDb() ) {
+				try {
+					Connection con = db().getConnection();
+					PreparedStatement stmt = con.prepareStatement(
+							"select message from node_msg where node_id=?"
+					);
+					stmt.setLong(1,getId());
+					ResultSet rs = stmt.executeQuery();
+					if( rs.next() ) {
+						raw = rs.getString("message");
+					} else {
+						logger.error("no message for "+NodeImpl.this+" - inserting empty message");
+						raw = "";
+						insertMessage(raw);
+					}
+					rs.close();
+					stmt.close();
+					con.close();
+				} catch(SQLException e) {
+					throw new RuntimeException(e);
+				}
+				if( raw==null )
+					throw new NullPointerException(toString());
+			}
+			return raw;
+		}
+
+		public Source getSource() {
+			if( record.isInDb() )
+				return NodeImpl.this;
+			Person owner = getOwner();
+			return owner instanceof User ? new Message.TempSource((User)owner) : null;
+		}
+
+		public boolean isDeleted() {
+			return isDeletedMessage();
+		}
+
+		public boolean isDeactivated() {
+			UserImpl user = getOwnerImpl();
+			return user != null && user.isDeactivated();
+		}
+	}
+
+	public Message getMessage() {
+		return message;
+	}
+
+	private boolean setMessage0(String messageRaw,Message.Format msgFmt) {
+		MyMessage newMessage = new MyMessage(messageRaw,msgFmt);
+		if( message.equals(newMessage) )
+			return false;
+		message = newMessage;
+		record.fields().put("msg_fmt",Character.toString(msgFmt.getCode()));
+		record.fields().put("message",messageRaw);
+		return true;
+	}
+
+	public void setMessage(String message,Message.Format msgFmt) {
+		boolean changed = setMessage0(message,msgFmt);
+		if (changed)
+			setWhenUpdated();
+	}
+
+	public void deleteMessageOrNode() {
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				NodeImpl node = DbUtils.getGoodCopy(NodeImpl.this);
+				node.deleteMessageOrNode();
+				db().commitTransaction();
+			} finally {
+				db().endTransaction();
+			}
+			return;
+		}
+		MailingListImpl mailingList = getMailingListImpl();
+		if (mailingList != null) {
+			mailingList.unsubscribe();
+		}
+		if( getChildCount() == 0 ) {
+			deleteRecursively();
+		} else {
+			setMessage(DELETED_MESSAGE.getRaw(),DELETED_MESSAGE.getFormat());
+			clearPending();
+			update();
+		}
+	}
+
+	private boolean isDeletedMessage() {
+		return getMessage().getRaw().equals(DELETED_MESSAGE.getRaw());
+	}
+
+	public void deleteRecursively() {
+		if( isRoot() ) {
+			getSiteImpl().delete();
+			return;
+		}
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				NodeImpl node = DbUtils.getGoodCopy(NodeImpl.this);
+				node.deleteRecursively();
+				db().commitTransaction();
+			} finally {
+				db().endTransaction();
+			}
+			return;
+		}
+		NodeImpl parent = getParentImpl();
+		record.delete();
+		removedNodeFrom(parent);
+		fireChangeListeners();
+	}
+
+	private synchronized void cacheMessage(String message) {
+		this.message.raw = message;
+	}
+
+	public Date getWhenCreated() {
+		return whenCreated;
+	}
+
+	public void setWhenCreated(Date whenCreated) {
+		this.whenCreated = whenCreated;
+		record.fields().put("when_created",whenCreated);
+		if( record.isInDb() && childCount==0 ) {
+			setLastNodeDate( whenCreated );
+			removedNodeFrom( getParentImpl() );
+			addNode();
+		}
+	}
+
+	private static final Object lastNodeLock = new Object();
+
+	public Node getLastNode() {
+		Node lastNode = getNode(lastNodeId);
+		if( lastNode == null ) {
+			logger.error("lastNode not found for "+this+" with lastNodeId="+lastNodeId,new NullPointerException());
+			// hack fix  -fschmidt
+			synchronized(lastNodeLock) {
+				DbUtils.uncache(this);
+				NodeImpl node = DbUtils.getGoodCopy(this);
+				if( lastNodeId != node.lastNodeId )
+					return getNode(node.lastNodeId);
+				populateLastNodeFields();
+			}
+			DbUtils.uncache(this);
+			NodeImpl node = DbUtils.getGoodCopy(this);
+			if( lastNodeId != node.lastNodeId )
+				return getNode(node.lastNodeId);
+			logger.error("after populateLastNodeFields, lastNode not found for "+node+" with lastNodeId="+node.lastNodeId);
+		}
+		return lastNode;
+	}
+
+	long getLastNodeId() {
+		return lastNodeId;
+	}
+
+	public Date getLastNodeDate() {
+		return lastNodeDate;
+	}
+
+	public Node getGoodCopy() {
+		return DbUtils.getGoodCopy(this);
+	}
+
+	public void insert(boolean isDoneByPoster) throws ModelException.TooManyPosts {
+		if( !db().isInTransaction() )
+			throw new RuntimeException();
+		if( kind==Kind.APP && type==null )
+			throw new RuntimeException("type not set for app");
+		if( isDoneByPoster ) {
+			UserImpl owner = getOwnerImpl();
+			if (owner != null)
+				owner.updateNewPostLimit();
+		}
+		if( isMailToList() && isDoneByPoster )
+			throw new RuntimeException("no more pending");
+		String message = (String)record.fields().remove("message");
+		record.insert();
+		insertMessage(message);
+		setLastNodeId( getId() );
+		setLastNodeDate( whenCreated );
+		record.update();
+		addNode();
+	}
+
+	private void insertMessage(String message) {
+		try {
+			Connection con = db().getConnection();
+			try {
+				PreparedStatement pstmt = con.prepareStatement(
+						"insert into node_msg (node_id,message) values (?,?)"
+				);
+				pstmt.setLong(1,getId());
+				pstmt.setString(2,message);
+				pstmt.executeUpdate();
+				pstmt.close();
+			} finally {
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	public void update() {
+		String message = (String)record.fields().remove("message");
+		if( message!=null && !db().isInTransaction() )
+			throw new RuntimeException();
+		record.update();
+		if( message != null ) {
+			try {
+				Connection con = db().getConnection();
+				PreparedStatement stmt = con.prepareStatement(
+						"update node_msg set message=? where node_id=?"
+				);
+				stmt.setLong(2,getId());
+				stmt.setString(1,message);
+				stmt.executeUpdate();
+				stmt.close();
+				con.close();
+			} catch(SQLException e) {
+				throw new RuntimeException(e);
+			}
+		}
+	}
+
+	void checkNewPostLimit() throws ModelException.TooManyPosts {
+		UserImpl owner = getOwnerImpl();
+		if (owner!=null && owner.hasTooManyPosts()) {
+			logger.warn( "Too many posts by "+owner );
+			throw new ModelException.TooManyPosts();
+		}
+	}
+
+	private void uncacheAncestors() {
+		for( NodeImpl node=this; node!=null; node=node.getParentImpl() ) {
+			DbUtils.uncache(node);
+		}
+	}
+
+	public void changeParent(Node n) throws ModelException {
+		NodeImpl node = (NodeImpl)n;
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				DbUtils.getGoodCopy(this).changeParent( node.getGoodCopy() );
+				db().commitTransaction();
+				return;
+			} finally {
+				db().endTransaction();
+			}
+		}
+		changeParentImpl(node);
+	}
+
+	void changeParentImpl(NodeImpl newParent) throws ModelException {
+		if( !isInDb() )
+			throw new RuntimeException();
+		if( !db().isInTransaction() )
+			throw new RuntimeException();
+
+		final NodeImpl oldParent = getParentImpl();
+		if( oldParent.siteKey != newParent.siteKey )
+			throw new RuntimeException("can't change site");
+		db().runAfterCommit(new Runnable(){public void run(){
+			oldParent.fireChildChangeListeners();
+		}});
+		record.fields().put("pin", DbNull.INTEGER);
+
+		if (newParent.getAncestors().contains(this))
+			throw new ModelException.NodeLoop(this);
+		parentId = newParent.getId();
+		record.fields().put("parent_id", parentId);
+		parent = null;
+
+		getDbRecord().update();
+		removedNodeFrom(oldParent);
+		addNode();
+		stale();
+
+		uncacheAncestors();
+	}
+
+	void makeRoot() {
+		record.fields().put("pin", DbNull.INTEGER);
+		parentId = 0L;
+		record.fields().put("parent_id", DbNull.INTEGER);
+		parent = null;
+		getDbRecord().update();
+		stale();
+	}
+
+	private void stale() {
+		Executors.executeAfterCommit(db(),new Runnable(){public void run(){
+			try {
+				Lucene.staleNode(DbUtils.getGoodCopy(NodeImpl.this));
+			} catch(IOException e) {
+				logger.error("StaleNode failed",e);
+			}
+		}});
+	}
+
+
+	private void addNode() {
+		NodeImpl parent = getParentImpl();
+		if( parent == null )
+			return;
+//		synchronized(siteKey.lastNodeLock) {
+			parent.setChildCount();
+			for( NodeImpl node = parent; node != null; node = node.getParentImpl() ) {
+				if( node.nodeCount==1 || node.lastNodeDate.before(lastNodeDate) ) {
+					node.setLastNodeId( lastNodeId );
+					node.setLastNodeDate( lastNodeDate );
+				}
+				node.setNodeCount();
+				node.record.update();
+			}
+//		}
+	}
+
+
+	private void removedNodeFrom(NodeImpl parent) {
+		if( parent == null )
+			return;
+//		synchronized(siteKey.lastNodeLock) {
+			parent.setChildCount();
+			try {
+				Connection con = db().getConnection();
+				try {
+					PreparedStatement pstmtGetLastNode = con.prepareStatement(
+						"select * from node"
+						+"	where parent_id = ?"
+						+"	order by last_node_date desc, node_id desc"
+						+"	limit 1"
+					);
+					for( NodeImpl node = parent; node != null; node = node.getParentImpl() ) {
+						if( node.lastNodeId == lastNodeId ) {
+							pstmtGetLastNode.setLong(1,node.getId());
+							ResultSet rs = pstmtGetLastNode.executeQuery();
+							if( rs.next() ) {
+								NodeImpl lastNode = getNode(rs);
+								node.setLastNodeId( lastNode.lastNodeId );
+								node.setLastNodeDate( lastNode.lastNodeDate );
+							} else {
+								node.setLastNodeId( node.getId() );
+								node.setLastNodeDate( node.whenCreated );
+							}
+							rs.close();
+						}
+						node.setNodeCount();
+						node.record.update();
+					}
+					pstmtGetLastNode.close();
+				} finally {
+					con.close();
+				}
+			} catch(SQLException e) {
+				throw new RuntimeException(e);
+			}
+//		}
+		parent.uncacheAncestors();
+	}
+
+	private void setLastNodeId(long lastNodeId) {
+		if( this.lastNodeId == lastNodeId )
+			return;
+		this.lastNodeId = lastNodeId;
+		record.fields().put("last_node_id", lastNodeId);
+	}
+
+	private void setLastNodeDate(Date lastNodeDate) {
+		if( ObjectUtils.equals(this.lastNodeDate,lastNodeDate) )
+			return;
+		this.lastNodeDate = lastNodeDate;
+		record.fields().put("last_node_date", lastNodeDate);
+	}
+
+	private void setNodeCount() {
+		int nodeCount = getCount( "select 1 + coalesce(sum(node_count),0) as n from node where parent_id = ?" );
+		if( this.nodeCount == nodeCount )
+			return;
+		this.nodeCount = nodeCount;
+		record.fields().put("node_count", nodeCount);
+	}
+
+	private void setChildCount() {
+		int childCount = getCount( "select count(*) as n from node where parent_id = ?" );
+		if( this.childCount == childCount )
+			return;
+		this.childCount = childCount;
+		record.fields().put("child_count", childCount);
+	}
+
+
+	public NodeIterator<? extends Node> getChildren() {
+		return getChildrenImpl(null);
+	}
+
+	public NodeIterator<? extends Node> getChildren(String cnd) {
+		return getChildrenImpl(cnd);
+	}
+
+	private static final Map<NodeImpl,List<NodeImpl>> dummyChildMap = Collections.synchronizedMap(new WeakHashMap<NodeImpl,List<NodeImpl>>());
+
+	NodeIterator<NodeImpl> getChildrenImpl(String cnd) {
+		if( !isInDb() ) {
+			List<NodeImpl> children = dummyChildMap.get(this);
+			if( children == null )
+				return NodeIterator.empty();
+			return NodeIterator.nodeIterator( children.iterator() );
+		}
+		return new CursorNodeIterator( siteKey,
+				"(select *"
+				+" from node"
+				+" where parent_id = ?"
+				+" and pin is not null "
+				+(cnd==null?"":"and " + cnd)
+				+" order by pin)"
+				+" union all"
+				+" (select *"
+				+" from node"
+				+" where parent_id = ?"
+				+" and pin is null "
+				+(cnd==null?"":"and " + cnd)
+				+" order by last_node_date desc, node_id desc)"
+			,
+				new DbParamSetter() {
+					public void setParams(PreparedStatement stmt) throws SQLException {
+						stmt.setLong( 1, getId() );
+						stmt.setLong( 2, getId() );
+					}
+				}
+		);
+	}
+
+	private int getCount(String sql) {
+		try {
+			Connection con = db().getConnection();
+			PreparedStatement stmt = con.prepareStatement(sql);
+			stmt.setLong(1,getId());
+			ResultSet rs = stmt.executeQuery();
+			rs.next();
+			int n = rs.getInt("n");
+			rs.close();
+			stmt.close();
+			con.close();
+			return n;
+		} catch(SQLException e) {
+			logger.error("sql = " + sql);
+			throw new RuntimeException(e);
+		}
+	}
+
+	public int getChildCount() {
+		return childCount;
+	}
+
+
+
+	public boolean isPinned() {
+		return isPinned;
+	}
+
+	public void pin(Node[] children) {
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				pin(children);
+				db().commitTransaction();
+			} finally {
+				db().endTransaction();
+			}
+			return;
+		}
+		try {
+			Connection con = db().getConnection();
+			try {
+				{
+					PreparedStatement stmt = con.prepareStatement(
+						"select node_id"
+						+" from node"
+						+" where parent_id = ?"
+						+" and pin is not null"
+					);
+					stmt.setLong(1,getId());
+					ResultSet rs = stmt.executeQuery();
+					while( rs.next() ) {
+						table().uncache( new LongKey(rs.getLong("node_id")) );
+					}
+					rs.close();
+					stmt.close();
+				}
+				PreparedStatement stmt = con.prepareStatement(
+					"update node set pin=null where parent_id=? and pin is not null"
+				);
+				stmt.setLong(1,getId());
+				stmt.executeUpdate();
+				stmt.close();
+				stmt = con.prepareStatement(
+					"update node set pin=? where node_id=?"
+				);
+				for( int i=0; i<children.length; i++ ) {
+					Node child = children[i];
+					if( !this.equals(child.getParent()) )
+						throw new RuntimeException(""+child+" not a child of "+this);
+					stmt.setInt(1,i+1);
+					stmt.setLong(2,child.getId());
+					stmt.executeUpdate();
+					DbUtils.uncache((NodeImpl)child);
+				}
+				stmt.close();
+			} finally {
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+		fireChildChangeListeners();
+	}
+
+
+	public long getExportedNodeId() {
+		return exportedNodeId;
+	}
+
+	public void setExportedNodeId(long id) {
+		if( id==0L )
+			throw new RuntimeException();
+		this.exportedNodeId = id;
+		record.fields().put( "exported_node_id", Long.valueOf(id) );
+		record.update();
+	}
+
+	public String getEmbeddingUrl() {
+		return embeddingUrl;
+	}
+
+	public void setEmbeddingUrl(String url) {
+		this.embeddingUrl = url;
+		record.fields().put( "embedding_url", DbNull.fix(embeddingUrl));
+		record.update();
+	}
+
+
+	@Override public boolean equals(Object obj) {
+		return this==obj || obj instanceof NodeImpl && record.isInDb() && ((NodeImpl)obj).getId()==getId();
+	}
+
+	@Override public int hashCode() {
+		return (int)getId();
+	}
+
+	@Override public String toString() {
+		return "node-"+getId();
+	}
+
+//	private final ListenerList<V> preInsertListeners = new ListenerList<V>();
+	static final ListenerList<NodeImpl> preUpdateListeners = new ListenerList<NodeImpl>();
+//	private final ListenerList<V> preDeleteListeners = new ListenerList<V>();
+	static final ListenerList<NodeImpl> postInsertListeners = new ListenerList<NodeImpl>();
+	static final ListenerList<NodeImpl> postUpdateListeners = new ListenerList<NodeImpl>();
+	static final ListenerList<NodeImpl> postDeleteListeners = new ListenerList<NodeImpl>();
+
+	private static Computable<SiteKey,DbTable<LongKey,NodeImpl>> tables = new SimpleCache<SiteKey,DbTable<LongKey,NodeImpl>>(new WeakHashMap<SiteKey,DbTable<LongKey,NodeImpl>>(), new Computable<SiteKey,DbTable<LongKey,NodeImpl>>() {
+		public DbTable<LongKey,NodeImpl> get(SiteKey siteKey) {
+			DbDatabase db = siteKey.getDb();
+			final long siteId = siteKey.getId();
+			DbTable<LongKey,NodeImpl> table = db.newTable("node",db.newIdentityLongKeySetter("node_id")
+				, new DbObjectFactory<LongKey,NodeImpl>() {
+					public NodeImpl makeDbObject(LongKey key,ResultSet rs,String tableName)
+						throws SQLException
+					{
+						SiteKey siteKey = SiteKey.getInstance(siteId);
+						return new NodeImpl(siteKey,key,rs);
+					}
+				}
+			);
+			table.getPreUpdateListeners().add(preUpdateListeners);
+			table.getPostInsertListeners().add(postInsertListeners);
+			table.getPostUpdateListeners().add(postUpdateListeners);
+			table.getPostDeleteListeners().add(postDeleteListeners);
+			return table;
+		}
+	});
+
+	static DbTable<LongKey,NodeImpl> table(SiteKey siteKey) {
+		return tables.get(siteKey);
+	}
+
+	static NodeImpl getNode(SiteKey siteKey,long id) {
+		return table(siteKey).findByPrimaryKey(new LongKey(id));
+	}
+
+	private NodeImpl getNode(long id) {
+		return getNode(siteKey,id);
+	}
+
+	static Collection<NodeImpl> getNodes(SiteKey siteKey,Collection<Long> ids) {
+		List<LongKey> list = new ArrayList<LongKey>();
+		for( long id : ids ) {
+			list.add( new LongKey(id) );
+		}
+		return table(siteKey).findByPrimaryKey(list).values();
+	}
+
+	static NodeImpl getNode(SiteKey siteKey,ResultSet rs)
+		throws SQLException
+	{
+		return table(siteKey).getDbObject(rs);
+	}
+
+	private NodeImpl getNode(ResultSet rs)
+		throws SQLException
+	{
+		return getNode(siteKey,rs);
+	}
+
+
+
+
+
+	private static final Collator collator = Collator.getInstance();
+	private CollationKey collationKey = null;
+
+	CollationKey getCollationKey() {
+		if( collationKey==null )
+			collationKey = collator.getCollationKey(getSubject());
+		return collationKey;
+	}
+
+
+
+	public Collection<Subscription> getSubscriptions(int i, int n) {
+		return getSubscriptions(
+			"select * from subscription where node_id = ? limit " + n + " offset " + i
+		);
+	}
+
+	Collection<Subscription> getSubscriptions(String sql) {
+		List<Subscription> list = new ArrayList<Subscription>();
+		try {
+			Connection con = db().getConnection();
+			try {
+				PreparedStatement stmt = con.prepareStatement(sql);
+				stmt.setLong( 1, getId() );
+				ResultSet rs = stmt.executeQuery();
+				while( rs.next() ) {
+					SubscriptionImpl subscription = SubscriptionImpl.getSubscription(siteKey,rs);
+					if( subscription != null )
+						list.add( subscription );
+				}
+				rs.close();
+				stmt.close();
+			} finally {
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+		return list;
+	}
+
+	public int getSubscriptionCount() {
+		db().beginTransaction();
+		try {
+			return getCount(
+				"select count(*) as n from subscription where node_id=?"
+			);
+		} finally {
+			db().endTransaction();
+		}
+	}
+
+	public Map<User,Subscription> getSubscribersToNotify() {
+		return SubscriptionImpl.getSubscribersToNotify(this);
+	}
+
+	public NodeIterator<? extends Node> getAncestors() {
+		return getAncestorImpls();
+	}
+
+    NodeIterator<NodeImpl> getAncestorImpls() {
+		return new NodeIterator<NodeImpl>() {
+			private NodeImpl next = NodeImpl.this;
+
+			public boolean hasNext() {
+				return next != null;
+			}
+
+			public NodeImpl next() {
+				if( !hasNext() )
+					throw new NoSuchElementException();
+				try {
+					return next;
+				} finally {
+					next = next.getParentImpl();
+				}
+			}
+
+			public void close() {
+				next = null;
+			}
+		};
+    }
+
+
+	synchronized UserImpl getOwnerImpl() {
+		if( ownerId!=0L && DbUtils.isStale(owner) ) {
+			owner = UserImpl.getUser(siteKey,ownerId);
+		}
+		return owner;
+	}
+
+	public final Person getOwner() {
+		UserImpl user = getOwnerImpl();
+		if( user != null )
+			return user;
+		if( cookie != null )
+			return new Anonymous(getSiteImpl(), cookie, anonymousName);
+		throw new RuntimeException();
+	}
+
+	public void setOwner(User owner) {
+		this.owner = null;
+		this.ownerId = owner.getId();
+		record.fields().put("owner_id", ownerId);
+		// Remove any anonymous information
+		record.fields().put("cookie", DbNull.STRING);
+		record.fields().put("anonymous_name", DbNull.STRING);
+	}
+
+
+	NodeImpl getAppImpl() {
+		for( NodeImpl node=this; node!=null; node=node.getParentImpl() ) {
+			if( node.getKind() == Kind.APP )
+				return node;
+		}
+		return null;
+	}
+
+	public final Node getApp() {
+		return getAppImpl();
+	}
+
+
+	static ListenerList<NodeImpl> gotParentListeners = new ListenerList<NodeImpl>();
+
+	synchronized NodeImpl getParentImpl() {
+		if( parentId == 0L && parent==null )
+			return null;
+		if( DbUtils.isStale(parent) ) {
+			parent = getNode(parentId);
+			if( parent == null )
+				logger.error(""+this+" parent="+parentId+" doesn't exist");
+			else if( parent.siteKey != siteKey )
+				logger.error(""+this+" parent="+parentId+" siteId="+siteKey+" parent.siteId="+parent.siteKey);
+			gotParentListeners.event(this);
+		}
+		return parent;
+	}
+
+	public final Node getParent(){
+		return getParentImpl();
+	}
+
+	SiteImpl getSiteImpl() {
+		return siteKey.site();
+	}
+
+	public Site getSite() {
+		return getSiteImpl();
+	}
+
+	public boolean isRoot() {
+		return parentId == 0L;
+	}
+
+
+	NodeImpl getTopicImpl() {
+		if( getKind() != Kind.POST )
+			return null;
+		NodeImpl node = this;
+		while(true) {
+			NodeImpl parent = node.getParentImpl();
+			if( parent==null || parent.getKind() != Kind.POST )
+				break;
+			node = parent;
+		}
+		return node;
+	}
+
+	public final Node getTopic() {
+		return getTopicImpl();
+	}
+
+	static void preloadMessages(List<Node> nodes) {
+		if( nodes.size()==0 )
+			return;
+		try {
+			Map<Long,NodeImpl> map = new HashMap<Long,NodeImpl>();
+			StringBuilder query = new StringBuilder();
+			query.append( "select node_id, message from node_msg where node_id in (" );
+			for (Node n : nodes) {
+				NodeImpl node = (NodeImpl) n;
+				if (node.message.hasLoaded())
+					continue;
+				if (!map.isEmpty())
+					query.append(',');
+				query.append(node.getId());
+				map.put(node.getId(), node);
+			}
+			if( map.isEmpty() )
+				return;
+			query.append( ')' );
+			Connection con = nodes.get(0).getSite().getDb().getConnection();
+			Statement stmt = con.createStatement();
+			ResultSet rs = stmt.executeQuery(query.toString());
+			while( rs.next() ) {
+				long nodeId = rs.getLong("node_id");
+				String message = rs.getString("message");
+				map.get(nodeId).cacheMessage(message);
+			}
+			rs.close();
+			stmt.close();
+			con.close();
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+
+	int getIntId() {
+		return getIntId(getId());
+	}
+
+	static int getIntId(long id) {
+		if( id > Integer.MAX_VALUE )
+			throw new RuntimeException();
+		return (int)id;
+	}
+
+	public Date getWhenUpdated() {
+		return whenUpdated;
+	}
+
+	void setWhenUpdated(Date whenUpdated) {
+		this.whenUpdated = whenUpdated;
+		record.fields().put("when_updated", whenUpdated);
+	}
+
+
+
+
+	static ListenerList<NodeImpl> childChangeListeners = new ListenerList<NodeImpl>();
+
+	private void fireChildChangeListeners() {
+		childChangeListeners.event(this);
+	}
+
+	static void addPostInsertListener(final Listener<? super NodeImpl> listener) {
+		postInsertListeners.add(listener);
+	}
+
+	static void addPostUpdateListener(final Listener<? super NodeImpl> listener) {
+		postUpdateListeners.add(listener);
+		Listener<MailingListImpl> mlListener = new Listener<MailingListImpl>() {
+			public void event(MailingListImpl ml) {
+				listener.event(ml.getForumImpl());
+			}
+		};
+		MailingListImpl.postInsertListeners.add(mlListener);
+		MailingListImpl.postUpdateListeners.add(mlListener);
+		MailingListImpl.postDeleteListeners.add(mlListener);
+	}
+
+	static void addPostDeleteListener(final Listener<? super NodeImpl> listener) {
+		postDeleteListeners.add(listener);
+	}
+
+
+	private static ListenerList<NodeImpl> changeListeners = new ListenerList<NodeImpl>();
+
+	private void fireChangeListeners() {
+		changeListeners.event(this);
+	}
+
+	static void addChangeListener(final Listener<? super NodeImpl> listener) {
+		addPostUpdateListener(listener);
+		addPostDeleteListener(listener);
+		changeListeners.add(listener);
+	}
+
+	private boolean hasNeighborTopic(boolean next) {
+		try {
+			Connection con = db().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+				"SELECT 1 " +
+				"FROM node " +
+				"WHERE parent_id = ? " +
+					"AND (is_app = 'f' or is_app is null) " +
+					"AND last_node_date " + (next?'<':'>') + " ? " +
+				"LIMIT 1"
+			);
+			stmt.setLong( 1, getParentId() );
+			stmt.setTimestamp( 2, new java.sql.Timestamp(getLastNodeDate().getTime()));
+			ResultSet rs = stmt.executeQuery();
+			try {
+				return rs.next();
+			} finally {
+				rs.close();
+				stmt.close();
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	private Node getNeighborTopic(boolean next) {
+		try {
+			Connection con = db().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+				"SELECT * "+
+				"FROM node "+
+				"WHERE parent_id = ? "+
+					"AND (is_app = 'f' or is_app is null) " +
+					"AND last_node_date " + (next?'<':'>') + " ? " +
+				"ORDER BY last_node_date " + (next?"DESC ":"ASC ") +
+				"LIMIT 1"
+			);
+
+			stmt.setLong( 1, getParentId() );
+			stmt.setTimestamp( 2, new java.sql.Timestamp(getLastNodeDate().getTime()));
+			ResultSet rs = stmt.executeQuery();
+			try {
+				return rs.next()? getNode(rs) : null;
+			} finally {
+				rs.close();
+				stmt.close();
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	public boolean hasPreviousTopic() {
+		return hasNeighborTopic(false);
+	}
+
+	public Node getPreviousTopic() {
+		return getNeighborTopic(false);
+	}
+
+	public boolean hasNextTopic() {
+		return hasNeighborTopic(true);
+	}
+
+	public Node getNextTopic() {
+		return getNeighborTopic(true);
+	}
+
+
+	public int getDescendantPostCount() {
+		return getDescendantCount() - getDescendantAppCount();
+	}
+
+	public int getDescendantAppCount() {
+		int count = 1;
+		for( Node f : getChildApps() ) {
+			count += f.getDescendantAppCount();
+		}
+		return count;
+	}
+
+
+	private Collection<NodeImpl> getDescendantApps(Filter<Node> filter) {
+		List<NodeImpl> list = new ArrayList<NodeImpl>();
+		getDescendantApps(filter,list);
+		return list;
+	}
+
+	private void getDescendantApps(Filter<Node> filter,Collection<NodeImpl> list) {
+		list.add(this);
+		for( Node f : getChildApps() ) {
+			NodeImpl node = (NodeImpl)f;
+			if( filter.ok(node) )
+				node.getDescendantApps(filter,list);
+		}
+	}
+
+	private boolean hasChildKind(Node.Kind kind) {
+		String cnd = kind == Node.Kind.APP? "is_app" : "(is_app = 'f' or is_app is null)";
+		try {
+			Connection con = db().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+				"select exists"
+				+" (select * from node"
+				+" where parent_id = ?"
+				+" and "
+				+ cnd
+				+") as b"
+			);
+			stmt.setLong( 1, getId() );
+			ResultSet rs = stmt.executeQuery();
+			rs.next();
+			try {
+				return rs.getBoolean("b");
+			} finally {
+				rs.close();
+				stmt.close();
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	private boolean hasPinnedKind(Node.Kind kind) {
+		String cnd = kind == Node.Kind.APP? "is_app" : "(is_app = 'f' or is_app is null)";
+		try {
+			Connection con = db().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+				"select exists"
+				+" (select 1 from node"
+				+" where parent_id = ?"
+				+" and pin is not null"
+				+" and "
+				+ cnd
+				+") as b"
+			);
+			stmt.setLong( 1, getId() );
+			ResultSet rs = stmt.executeQuery();
+			rs.next();
+			try {
+				return rs.getBoolean("b");
+			} finally {
+				rs.close();
+				stmt.close();
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	public boolean hasChildApps() {
+		return hasChildKind(Node.Kind.APP);
+	}
+
+	public boolean hasChildTopics() {
+		return hasChildKind(Node.Kind.POST);
+	}
+
+	public boolean hasPinnedApps() {
+		return hasPinnedKind(Node.Kind.APP);
+	}
+
+	public boolean hasPinnedTopics() {
+		return hasPinnedKind(Node.Kind.POST);
+	}
+
+	public NodeIterator<? extends Node> getChildApps() {
+		return getChildApps(null);
+	}
+
+	public NodeIterator<? extends Node> getChildApps(String cnd) {
+		cnd = cnd==null ? "is_app" : "is_app and (" + cnd + ")";
+		return getChildrenImpl(cnd);
+	}
+
+	public String getType() {
+		return type;
+	}
+
+	public void setType(String type) {
+		this.type = type;
+		record.fields().put( "type",
+			Type.COMMENT.equals(type) ? DbNull.STRING : type
+		);
+	}
+
+
+	public boolean isInDb() {
+		return record.isInDb();
+	}
+
+
+	public int getDescendantCount() {
+		return nodeCount;
+	}
+
+	public Message.SourceType getMessageSourceType() {
+		return Message.SourceType.NODE;
+	}
+
+
+	static void nop() {}
+
+
+	private DbParamSetter simpleParamSetter() {
+		return new DbParamSetter() {
+			public void setParams(PreparedStatement stmt) throws SQLException {
+				stmt.setLong( 1, getId() );
+			}
+		};
+	}
+
+	private List<NodeImpl> childAppList() {
+		return new CursorNodeIterator( siteKey,
+				"select * from node where parent_id = ? and is_app"
+			, simpleParamSetter()
+		).asList();
+	}
+
+	private int getTopicCount(Filter<Node> filter) {
+		int n = childCount;
+		for( NodeImpl childApp : childAppList() ) {
+			n--;
+			if( filter.ok(childApp) )
+				n += childApp.getTopicCount(filter);
+		}
+		return n;
+	}
+
+	private int getTopicCount2(String cnd,Filter<Node> filter) {
+		int n = getCount(
+			"select count(*) as n from node where parent_id=? and is_app is null and (" + cnd + ")"
+		);
+		for( NodeImpl childApp : childAppList() ) {
+			if( filter.ok(childApp) )
+				n += childApp.getTopicCount2(cnd,filter);
+		}
+		return n;
+	}
+
+	public int getTopicCount(String cnd,Filter<Node> filter) {
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				return getTopicCount(cnd,filter);
+			} finally {
+				db().endTransaction();
+			}
+		}
+		return getTopicCount0(cnd, filter);
+	}
+
+	private Map<String, Integer> topicCountCache;
+
+	private synchronized int getTopicCount0(String cnd, Filter<Node> filter) {
+		if (topicCountCache == null)
+			topicCountCache = new HashMap<String, Integer>();
+		String cacheKey = (cnd == null? "" : cnd + '|') + filter.getClass().getName();
+		Integer countValue = topicCountCache.get(cacheKey);
+		if (countValue == null) {
+			countValue = cnd==null ? getTopicCount(filter) : getTopicCount2(cnd,filter);
+			topicCountCache.put(cacheKey, countValue);
+		}
+		return countValue;
+	}
+
+
+	private static class MyIter {
+		NodeImpl node;
+		Comparable cmp;
+		final NodeIterator<NodeImpl> iter;
+
+		MyIter(NodeImpl node,Comparable cmp,NodeIterator<NodeImpl> iter) {
+			this.node = node;
+			this.cmp = cmp;
+			this.iter = iter;
+		}
+	}
+
+	private class OrderedNodeIterator extends NodeIterator<NodeImpl> {
+		private final List<MyIter> mis = new ArrayList<MyIter>();
+		NodeImpl next = null;
+		private final boolean isTopics;
+		private final boolean skipPinned;
+		private final Order order;
+		private final String sql;
+		private final Filter<Node> filter;
+
+		OrderedNodeIterator(boolean isTopics,boolean skipPinned,String cnd,Filter<Node> filter,Order order) {
+			if( !isTopics && skipPinned )
+				throw new UnsupportedOperationException();
+			this.isTopics = isTopics;
+			this.skipPinned = skipPinned;
+			this.order = order;
+			this.filter = filter;
+			StringBuilder buf = new StringBuilder();
+			buf.append( "select * from node where parent_id = ?" );
+			if( cnd != null )
+				buf.append( " and (is_app or (" + cnd + ")) " );
+			buf.append( " order by " ).append( order.sqlOrder() );
+			this.sql = buf.toString();
+			NodeIterator<NodeImpl> empty = NodeIterator.empty();
+			mis.add( new MyIter(NodeImpl.this,order.getComparable(NodeImpl.this),empty) );
+		}
+
+		void add(MyIter mi) {
+			int len = mis.size();
+			for( int i=0; i<len; i++ ) {
+				@SuppressWarnings("unchecked")
+				boolean precedes = mi.cmp.compareTo(mis.get(i).cmp) < 0;
+				if( precedes ) {
+					mis.add(i,mi);
+					return;
+				}
+			}
+			mis.add(mi);
+		}
+
+		public boolean hasNext() {
+			if( next != null )
+				return true;
+			while( !mis.isEmpty() ) {
+				MyIter mi = mis.remove(0);
+				if( mi.iter == null ) {
+					next = mi.node;
+					return true;
+				}
+				final NodeImpl node = mi.node;
+				if( mi.iter.hasNext() ) {
+					mi.node = mi.iter.next();
+					mi.cmp = order.getComparable(mi.node);
+					add(mi);
+				}
+				if( node != NodeImpl.this && filter != null && !filter.ok(node) )
+					continue;
+				boolean isPost = node.getKind() == Kind.POST;
+				if( isPost && isTopics ) {
+					if( skipPinned && node.isPinned() && node.parentId==getId() )
+						continue;
+					next = node;
+					return true;
+				}
+				NodeIterator<NodeImpl> children = new CursorNodeIterator( siteKey, sql,
+					new DbParamSetter() {
+						public void setParams(PreparedStatement stmt) throws SQLException {
+							stmt.setLong( 1, node.getId() );
+						}
+					}
+				);
+				if( children.hasNext() ) {
+					NodeImpl firstChild = children.next();
+					add( new MyIter(firstChild,order.getComparable(firstChild),children) );
+				}
+				if( isPost ) {
+					Comparable postCmp = order.getPostComparable(node);
+					if( postCmp != order.getComparable(node) ) {
+						add( new MyIter(node,postCmp,null) );
+					} else {
+						next = node;
+						return true;
+					}
+				}
+			}
+			return false;
+		}
+
+		public NodeImpl next() throws NoSuchElementException {
+			if( !hasNext() )
+				throw new NoSuchElementException();
+			try {
+				return next;
+			} finally {
+				next = null;
+			}
+		}
+
+		public void close() {
+			while( !mis.isEmpty() ) {
+				NodeIterator<NodeImpl> iter = mis.remove(0).iter;
+				if( iter != null )
+					iter.close();
+			}
+		}
+	}
+
+	private static class ConcatNodeIterator extends NodeIterator<NodeImpl> {
+		private final NodeIterator<NodeImpl>[] a;
+		private int i = 0;
+
+		ConcatNodeIterator(NodeIterator<NodeImpl>... a) {
+			this.a = a;
+		}
+
+		public boolean hasNext() {
+			while(true) {
+				if( i==a.length )
+					return false;
+				if( a[i].hasNext() )
+					return true;
+				a[i++].close();
+			}
+		}
+
+		public NodeImpl next() throws NoSuchElementException {
+			return a[i].next();
+		}
+
+		public void close() {
+			while( i < a.length ) {
+				a[i++].close();
+			}
+		}
+	}
+
+	private String fixCnd(String cnd) {
+		if( cnd==null )
+			return "";
+		cnd = cnd.trim();
+		return cnd.length()==0 ? "" : " and (" + cnd + ") ";
+	}
+
+	private NodeIterator<NodeImpl> getPinned(String cnd) {
+		cnd = fixCnd(cnd);
+		return new CursorNodeIterator( siteKey,
+				"select * from node where pin is not null and parent_id = ? and is_app is null"
+				+ cnd
+				+" order by pin"
+			, simpleParamSetter()
+		);
+	}
+
+
+	public NodeIterator<? extends Node> getTopicsByPinnedAndLastNodeDate(String cnd,Filter<Node> filter) {
+		@SuppressWarnings("unchecked")
+		NodeIterator<NodeImpl> i = new ConcatNodeIterator(
+			getPinned(cnd),
+			new OrderedNodeIterator(true,true,cnd,filter,Order.BY_LAST_NODE_DATE_DESC)
+		);
+		return i;
+	}
+
+	public NodeIterator<? extends Node> getPostsByDate(Filter<Node> filter) {
+		return new OrderedNodeIterator(false,false,null,filter,Order.BY_DATE_DESC);
+	}
+
+	public NodeIterator<? extends Node> getPostsByDateAscending(Filter<Node> filter) {
+		return new OrderedNodeIterator(false,false,null,filter,Order.BY_WHEN_CREATED);
+	}
+
+	public NodeIterator<? extends Node> getTopicsByLastNodeDate(String cnd,Filter<Node> filter) {
+		return new OrderedNodeIterator(true,false,cnd,filter,Order.BY_LAST_NODE_DATE_DESC);
+	}
+
+	public NodeIterator<? extends Node> getTopicsBySubject(String cnd,Filter<Node> filter) {
+		return new OrderedNodeIterator(true,false,cnd,filter,Order.BY_SUBJECT);
+	}
+
+	public NodeIterator<? extends Node> getTopicsByPinnedAndRootNodeDate(String cnd,Filter<Node> filter) {
+		String fixedCnd = fixCnd(cnd);
+		StringBuilder sql = new StringBuilder();
+		Collection<NodeImpl> apps = getDescendantApps(filter);
+		apps.remove(this);
+		sql.append( "select * from node where parent_id=" ).append( getId() )
+			.append( " and is_app is null and pin is null" ).append( fixedCnd );
+		if( !apps.isEmpty() ) {
+			sql.append( " or parent_id in (" );
+			Iterator<NodeImpl> iter = apps.iterator();
+			sql.append( iter.next().getId() );
+			while( iter.hasNext() ) {
+				sql.append( "," ).append( iter.next().getId() );
+			}
+			sql.append( ") and is_app is null" ).append( fixedCnd );
+		}
+		sql.append(" order by when_created desc");
+
+		@SuppressWarnings("unchecked")
+		NodeIterator<NodeImpl> i = new ConcatNodeIterator(
+			getPinned(cnd),
+			new CursorNodeIterator( siteKey, sql.toString(), DbParamSetter.NONE )
+		);
+		return i;
+	}
+
+	public NodeIterator<? extends Node> getTopicsByPopularity(String cnd,Filter<Node> filter) {
+		String fixedCnd = fixCnd(cnd);
+		StringBuilder sql = new StringBuilder();
+		Collection<NodeImpl> apps = getDescendantApps(filter);
+		sql.append( "SELECT n.* FROM node n, view_count vc ")
+			.append("WHERE n.node_id = vc.node_id ")
+			.append("AND is_app is null ")
+			.append("AND pin is null " )
+			.append(fixedCnd)
+			.append("AND parent_id in (");
+		Iterator<NodeImpl> iter = apps.iterator();
+		sql.append( iter.next().getId() );
+		while( iter.hasNext() ) {
+			sql.append( "," ).append( iter.next().getId() );
+		}
+		sql.append(") ")
+			.append("ORDER BY views desc");
+
+		@SuppressWarnings("unchecked")
+		NodeIterator<NodeImpl> i = new ConcatNodeIterator(
+			getPinned(cnd),
+			new CursorNodeIterator( siteKey, sql.toString(), DbParamSetter.NONE )
+		);
+		return i;
+	}
+
+	public NodeIterator<? extends Node> getDescendants() {
+		return getDescendantImpls();
+	}
+
+	NodeIterator<NodeImpl> getDescendantImpls() {
+		return getDescendantImpls(null);
+	}
+
+	public NodeIterator<? extends Node> getDescendantApps() {
+		return getDescendantImpls("is_app");
+	}
+
+	private NodeIterator<NodeImpl> getDescendantImpls(String cnd) {
+		List<NodeImpl> list = new ArrayList<NodeImpl>();
+		list.add(this);
+		int i = 0;
+		while( i < list.size() ) {
+			NodeImpl node = list.get(i++);
+			for( NodeImpl child : node.getChildrenImpl(cnd) ) {
+				list.add(child);
+			}
+		}
+		return NodeIterator.nodeIterator(list);
+	}
+
+	public long getSourceId() {
+		return getId();
+	}
+
+
+
+	public void populateLastNodeFields() {
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				DbUtils.getGoodCopy(this).populateLastNodeFields();
+				db().commitTransaction();
+			} finally {
+				db().endTransaction();
+			}
+			return;
+		}
+//		synchronized(siteKey.lastNodeLock) {
+			populateLastNodeFields2();
+//		}
+	}
+
+	private void populateLastNodeFields2() {
+		NodeImpl lastChild = null;
+		NodeIterator<NodeImpl> nodes = new CursorNodeIterator( siteKey,
+				"select * from node where parent_id = ?"
+			, simpleParamSetter()
+		);
+		for( NodeImpl child : nodes ) {
+			child.populateLastNodeFields2();
+			if( lastChild==null || lastChild.lastNodeDate.before(child.lastNodeDate) )
+				lastChild = child;
+		}
+		if( lastChild==null ) {
+			setLastNodeId( getId() );
+			setLastNodeDate( whenCreated );
+		} else {
+			setLastNodeId( lastChild.lastNodeId );
+			setLastNodeDate( lastChild.lastNodeDate );
+		}
+		setNodeCount();
+		setChildCount();
+		if( !record.fields().isEmpty() )
+			record.update();
+	}
+
+
+
+	// mailing list related
+
+	static final String messageIDEnding;
+	static {
+		try {
+			messageIDEnding = ".post@" + Init.get("mailDomain",InetAddress.getLocalHost().getHostName());
+		} catch(UnknownHostException e) {
+			logger.error("",e);
+			System.exit(-1);
+			throw new RuntimeException();  // for compiler
+		}
+	}
+
+	public String getMessageID() {
+		return messageID;
+	}
+
+	public MailFromList getParentMailFromList() {
+		Node node = getParent();
+		return (node == null) ? null : node.getMailFromList();
+	}
+
+	public String getOrGenerateMessageID() {
+		if (messageID == null) {
+			String id = "" + System.currentTimeMillis() + "-" + getId() + messageIDEnding;
+			setMessageID(id);
+			if( isInDb() )
+				this.record.update();
+		}
+		return messageID;
+	}
+
+	void setMessageID(String messageID) {
+		this.messageID = messageID;
+		record.fields().put("message_id",messageID);
+	}
+
+	void setGuessedParent(NodeImpl newParent)
+		throws ModelException
+	{
+		setGuessedParent(true);
+		changeParentImpl(newParent);
+	}
+
+	boolean isGuessedParent() {
+		return (isGuessedParent != null) && isGuessedParent;
+	}
+
+	boolean isNotGuessedParent() {
+		return (isGuessedParent != null) && !isGuessedParent;
+	}
+
+	public boolean hasGuessedParent() {
+		return isGuessedParent();
+	}
+
+	Boolean rawGuessedParent() {
+		return isGuessedParent;
+	}
+
+	/**
+	 * Set 'guessed_parent' flag of the post.
+	 *
+	 * @param guessed flag value
+	 */
+	void setGuessedParent(Boolean guessed) {
+		this.isGuessedParent = guessed;
+		record.fields().put("guessed_parent", DbNull.fix(this.isGuessedParent) );
+	}
+
+	void setGuessedParent(String parentID) {
+		setGuessedParent(Boolean.TRUE);
+		setRawParentMessageId(parentID);
+	}
+
+	// only returns MailingList if not isUIHidden
+	public MailingList getAssociatedMailingList() {
+		return getAssociatedMailingListImpl();
+	}
+
+	MailingListImpl getAssociatedMailingListImpl() {
+		for( NodeImpl node=this; node!=null; node=node.getParentImpl() ) {
+			if( node.getKind()==Kind.APP ) {
+				MailingListImpl ml = node.getMailingListImpl();
+				if( ml != null )
+					return ml;
+			}
+		}
+		return null;
+	}
+
+	boolean isFromMailingList() {
+		return message.getFormat() == Message.Format.MAILING_LIST;
+	}
+
+	void clearPending() {
+		whenSent = null;
+		record.fields().put("when_sent", DbNull.TIMESTAMP);
+		if( !db().isInTransaction() ) {
+			record.update();
+			DbUtils.uncache(getOwnerImpl());
+		}
+	}
+
+	private boolean isMailToList() {
+		return getKind() == Kind.POST && getAssociatedMailingListImpl() != null && !isFromMailingList();
+	}
+
+	public MailToList getMailToList() {
+		if( !isMailToList() )
+			return null;
+		return new MailToList() {
+
+			public Node getNode() {
+				return NodeImpl.this;
+			}
+
+			public boolean isPending() {
+				return whenSent != null;
+			}
+
+			public void clearPending() {
+				NodeImpl.this.clearPending();
+			}
+
+			public Date getWhenSent() {
+				return whenSent;
+			}
+
+			public String getOrGenerateMessageID() {
+				return NodeImpl.this.getOrGenerateMessageID();
+			}
+
+		};
+	}
+
+
+	public MailFromList getMailFromList() {
+		//  MailFromList is used to work with all messages from mailing lists, both local and external
+		return (getKind() == Kind.POST && getAssociatedMailingListImpl() != null) ? this : null;
+	}
+
+	public MailingList newMailingList(ListServer listServer,String listAddress,String url) throws ModelException {
+		if( getKind() != Kind.APP )
+			throw new UnsupportedOperationException();
+		if( !ModelHome.insideImportProcedure.get() )
+			DailyNumber.forumsStarted.dec();
+        setMailingList(new MailingListImpl(this, listServer, listAddress, url));
+        DbUtils.uncache(this);
+        return mailingList;
+	}
+
+	private void setRawParentMessageId(String parentMessageId) {
+		record.fields().put("parent_message_id", DbNull.fix(parentMessageId));
+	}
+
+	static NodeImpl[] getFromParentID(String parentID, MailingListImpl mailingList) {
+		try {
+			SiteKey siteKey = mailingList.siteKey;
+			Connection con = siteKey.getDb().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+					"select *"
+					+" from node"
+					+" where lower(parent_message_id)=?"
+			);
+			stmt.setString(1,parentID.toLowerCase());
+			ResultSet rs = stmt.executeQuery();
+			try {
+				List<NodeImpl> list = new ArrayList<NodeImpl>();
+				while( rs.next() ) {
+					NodeImpl post = getNode(siteKey,rs);
+					if (mailingList.equals(post.getAssociatedMailingListImpl()))
+						list.add( post );
+				}
+				return list.toArray(new NodeImpl[0]);
+			} finally {
+				rs.close();
+				stmt.close();
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	NodeImpl getNodeImplFromMessageID(String messageID) {
+		if( messageID == null )
+			return null;
+		try {
+			Connection con = db().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+					"select * from node"
+					+" where lower(message_id)=? and message_id is not null"
+			);
+			stmt.setString(1,messageID.toLowerCase());
+			ResultSet rs = stmt.executeQuery();
+			try {
+				while (rs.next()) {
+					NodeImpl post = getNode(rs);
+					if( post.getAncestors().contains(this) )
+						return post;
+				}
+				return null;
+			} finally {
+				rs.close();
+				stmt.close();
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+
+
+
+
+	private MailingListImpl mailingList;
+    private boolean mailingListSet = false;
+
+    private void setMailingList(MailingListImpl mailingList) {
+        this.mailingList = mailingList;
+        mailingListSet = true;
+    }
+
+	MailingListImpl getMailingListImpl() {
+		if( getKind() != Kind.APP )
+			return null;
+        if (!mailingListSet || (mailingList != null && DbUtils.isStale(mailingList))) {
+            setMailingList(MailingListImpl.getMailingListForForum(this));
+        }
+        return mailingList;
+    }
+
+	public MailingList getMailingList() {
+		return getMailingListImpl();
+	}
+
+	public void deleteMailingList() {
+		MailingList mailingList = getMailingList();
+		if (mailingList != null) {
+			mailingList.delete();
+			this.mailingList = null;
+			this.mailingListSet = false;
+			DbUtils.uncache(this);
+		}
+	}
+
+
+
+
+	// export/import
+
+	NodeImpl(SiteImpl site,NodeData data)
+		throws ModelException
+	{
+		this(
+			site.siteKey,
+			Kind.valueOf(data.kind),
+			data.ownerAnonymousId == null ?
+				site.getOrCreateUser(data.ownerEmail,data.ownerName) :
+				new Anonymous(site, data.ownerAnonymousId, data.ownerName),
+			data.subject,
+			data.message,
+			Message.Format.getMessageFormat(data.msgFmt)
+		);
+		if( data.parentId != null ) {
+			setParent( getNode(data.parentId) );
+		}
+		setWhenCreated( data.whenCreated );
+		if( data.whenUpdated != null )
+			setWhenUpdated( data.whenUpdated );
+		setType( data.type );
+		if( data.pin != null )
+			record.fields().put( "pin", data.pin );
+		if( data.messageID != null )
+			setMessageID( data.messageID );
+		setGuessedParent( data.isGuessedParent );
+		insert(false);
+
+		if( data.mlAddress != null ) {
+			ListServer listServer = ListServer.getServer( data.mlServer );
+			MailingList mailingList = newMailingList( listServer, data.mlAddress, data.mlUrl );
+			mailingList.setPlainTextOnly( data.mlPlainTextOnly );
+			mailingList.setIgnoreNoArchive( data.mlIgnoreNoArchive );
+			mailingList.setListName( data.mlListName );
+			mailingList.update();
+			update();
+		}
+
+		for( URL url : data.fileUrls ) {
+			try {
+				FileUpload.uploadFile( new FileUpload.UrlFileItem(url), this );
+			} catch(ModelException e) {}
+		}
+
+		// Only for backup recovery
+		if (data.files != null && data.files.size() > 0) {
+			Set<Map.Entry<String, byte[]>> entries = data.files.entrySet();
+			for (Map.Entry<String, byte[]> entry : entries) {
+				FileUpload.saveFile(entry.getValue(), entry.getKey(), this);
+			}
+		}
+
+		for( ExtensionFactory<Node,?> factory : extensionFactories ) {
+			Serializable obj = data.extensionData.get(factory.getName());
+			if( obj != null )
+				factory.saveExportData(this,obj);
+		}
+	}
+
+	public NodeData getData() {
+		NodeData data = new NodeData();
+
+		data.exportId = getId();
+		data.kind = kind.toString();
+		Person owner = getOwner();
+		if (owner instanceof Anonymous)
+			data.ownerAnonymousId = ((Anonymous)owner).getCookie();
+		else
+			data.ownerEmail = ((User) owner).getEmail();
+		data.ownerName = owner.getName();
+		data.subject = subject;
+		data.message = getMessage().getRaw();
+		data.msgFmt = getMessage().getFormat().getCode();
+		data.whenCreated = whenCreated;
+		data.whenUpdated = whenUpdated;
+		data.type = type;
+		data.messageID = messageID;
+		data.isGuessedParent = isGuessedParent;
+
+		MailingListImpl mailingList = getMailingListImpl();
+		if( mailingList != null ) {
+			data.mlAddress = mailingList.getListAddress();
+			data.mlUrl = mailingList.getUrl();
+			if( data.mlUrl==null )
+				data.mlUrl = "http://localhost";
+			data.mlPlainTextOnly = mailingList.plainTextOnly();
+			data.mlIgnoreNoArchive = mailingList.ignoreNoArchive();
+			data.mlServer = mailingList.getListServer().getType();
+			data.mlListName = mailingList.getListName();
+		}
+
+		List<URL> urls = new ArrayList<URL>();
+		Message.Format fmt = message.getFormat();
+		if( !(fmt instanceof MailMessageFormat) ) {
+			Html list = message.parse();
+			Map<String,String> files = FileUpload.getFileInfo(list,this);
+			for( String url : files.values() ) {
+				try {
+					urls.add( new URL(url) );
+				} catch(MalformedURLException e) {
+					throw new RuntimeException(e);
+				}
+			}
+		}
+		data.fileUrls = urls.toArray(new URL[0]);
+
+		for( ExtensionFactory<Node,?> factory : extensionFactories ) {
+			Serializable obj = factory.getExportData(this);
+			if( obj != null )
+				data.extensionData.put(factory.getName(),obj);
+		}
+
+		return data;
+	}
+
+
+
+
+
+
+
+
+	private static final String EXPORT_TASK = "export";
+
+	private static class LazyExports {
+		static {
+			try {
+				for( SiteKey siteKey : SiteKey.getSiteKeys(EXPORT_TASK) ) {
+					Connection con = siteKey.getDb().getConnection();
+					try {
+						Statement stmt = con.createStatement();
+						ResultSet rs = stmt.executeQuery(
+							"select * from node"
+							+" where export_permalink is not null"
+						);
+						while( rs.next() ) {
+							// Put the task back because the export hasn't finished yet and
+							// it should keep trying until everything has moved. Since the node
+							// is deleted at the end of the export, the SQL above will not find any
+							// node to be migrated when the export finishes. So this task loop will
+							// finally come to an end.
+							siteKey.site().addTask(EXPORT_TASK);
+
+							NodeImpl node = getNode(siteKey,rs);
+							// Logs node/site ids because we may need this in the shell.
+							logger.error("Export restarted for node=" + node.getId() + " [site=" + node.getSite().getId() + ']');
+							// Continue exporting...
+							node.doExport();
+						}
+						rs.close();
+						stmt.close();
+					} finally {
+						con.close();
+					}
+				}
+			} catch(SQLException e) {
+				throw new RuntimeException(e);
+			}
+		}
+		static void start() {}
+	}
+
+	static {
+		Executors.schedule(
+			new Runnable(){public void run(){
+				LazyExports.start();
+			}}, 200, TimeUnit.SECONDS
+		);
+	}
+
+	public void export(String permalink,String email) {
+		LazyExports.start();
+		setExport(permalink,email);
+		doExport();
+	}
+
+	private void setExport(String permalink,String email) {
+		if( exportPermalink != null )
+			throw new RuntimeException("already set");
+		this.exportPermalink = permalink;
+		getDbRecord().fields().put("export_permalink",exportPermalink);
+		this.exportEmail = email;
+		getDbRecord().fields().put("export_email",exportEmail);
+		getDbRecord().update();
+		getSiteImpl().addTask(EXPORT_TASK);
+	}
+
+	/* to be called from luan shell */
+	public void clearExport() {
+		if( exportPermalink == null )
+			throw new RuntimeException("already cleared");
+		this.exportPermalink = null;
+		getDbRecord().fields().put("export_permalink",DbNull.STRING);
+		this.exportEmail = null;
+		getDbRecord().fields().put("export_email",DbNull.STRING);
+		getDbRecord().update();
+	}
+
+	/* to be called from luan shell */
+	public static void clearExportedNodeIds(Node node) {
+		if (node.getExportedNodeId() > 0) {
+			node.getDbRecord().fields().put( "exported_node_id", DbNull.INTEGER );
+			node.getDbRecord().update();
+			for (Node child : node.getChildren()) {
+				clearExportedNodeIds(child);
+			}
+		}
+	}
+
+	private void doExport() {
+		final Export export;
+		try {
+			export = new Export(this, exportPermalink, exportEmail);
+		} catch(IOException e) {
+			throw new RuntimeException(e);
+		}
+		Executors.executeNow(new Runnable(){public void run(){
+			export.run();
+			//clearExport();
+		}});
+	}
+
+
+
+
+	private Map<ExtensionFactory<Node,?>,Object> extensionMap;
+
+	public synchronized Map<ExtensionFactory<Node, ?>, Object> getExtensionMap() {
+		if (extensionMap == null)
+			extensionMap = new HashMap<ExtensionFactory<Node, ?>, Object>();
+		return extensionMap;
+	}
+
+	public <T> T getExtension(ExtensionFactory<Node,T> factory) {
+		synchronized(getExtensionMap()) {
+			Object obj = extensionMap.get(factory);
+			if( obj == null ) {
+				obj = factory.construct(this);
+				if( obj != null )
+					extensionMap.put(factory,obj);
+			}
+			return factory.extensionClass().cast(obj);
+		}
+	}
+
+	private static Collection<ExtensionFactory<Node,?>> extensionFactories = new CopyOnWriteArrayList<ExtensionFactory<Node,?>>();
+
+	static <T> void addExtensionFactory(ExtensionFactory<Node,T> factory) {
+		extensionFactories.add(factory);
+		Db.clearCache();
+	}
+
+
+
+	private final Memoizer<String,String> propertyCache = new Memoizer<String,String>(new Computable<String,String>() {
+		public String get(String key) {
+			try {
+				Connection con = db().getConnection();
+				PreparedStatement stmt = con.prepareStatement(
+					"select value from node_property where node_id = ? and key = ?"
+				);
+				stmt.setLong( 1, getId() );
+				stmt.setString( 2, key );
+				ResultSet rs = stmt.executeQuery();
+				try {
+					return rs.next() ? rs.getString("value") : null;
+				} finally {
+					rs.close();
+					stmt.close();
+					con.close();
+				}
+			} catch(SQLException e) {
+				throw new RuntimeException(e);
+			}
+		}
+	});
+
+	public String getProperty(String key) {
+		return propertyCache.get(key);
+	}
+
+	public void setProperty(String key,String value) {
+		try {
+			Connection con = db().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+				"delete from node_property where node_id = ? and key = ?"
+			);
+			stmt.setLong( 1, getId() );
+			stmt.setString( 2, key );
+			stmt.executeUpdate();
+			stmt.close();
+			if( value != null ) {
+				stmt = con.prepareStatement(
+					"insert into node_property (node_id,key,value) values (?,?,?)"
+				);
+				stmt.setLong( 1, getId() );
+				stmt.setString( 2, key );
+				stmt.setString( 3, value );
+				stmt.executeUpdate();
+				stmt.close();
+			}
+			con.close();
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		} finally {
+			propertyCache.remove(key);
+		}
+	}
+
+
+	final Memoizer<String,Boolean> tagCache = new Memoizer<String,Boolean>(new Computable<String,Boolean>() {
+		public Boolean get(String sqlCondition) {
+			return TagImpl.countTags(siteKey,sqlCondition) > 0;
+		}
+	});
+
+}