view src/nabble/model/NodeImpl.java @ 47:72765b66e2c3

remove mailing list code
author Franklin Schmidt <fschmidt@gmail.com>
date Fri, 18 Jun 2021 17:44:24 -0600
parents 7ecd1a4ef557
children
line wrap: on
line source

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 {
	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 Date whenUpdated;
	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");
		whenUpdated = rs.getTimestamp("when_updated");
		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();
		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;
		}
		if( getChildCount() == 0 ) {
			deleteRecursively();
		} else {
			setMessage(DELETED_MESSAGE.getRaw(),DELETED_MESSAGE.getFormat());
			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();
		}
		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);
	}

	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();
	}


	// 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 );
		insert(false);

		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;

		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;
		}
	});

}