site

(djk)
2011-04-29: First crack at glog style version display.

First crack at glog style version display.

diff --git a/alien/src/fmsutil/FMSUtil.java b/alien/src/fmsutil/FMSUtil.java
--- a/alien/src/fmsutil/FMSUtil.java
+++ b/alien/src/fmsutil/FMSUtil.java
@@ -83,7 +83,7 @@ public class FMSUtil {
     }
 
     // Set true to data written to / read from the nntp server to stdout.
-    public static boolean sNNTPDebugging = true;
+    public static boolean sNNTPDebugging = false;
 
     public final static String SUBJECT_PREFIX = "BISS|";
 
@@ -91,9 +91,14 @@ public class FMSUtil {
         return key.startsWith("SSK@"); // DCI: do much better.
     }
 
+    private static void debug(String text) {
+        if (!sNNTPDebugging) { return; }
+        System.err.println("FMSUtil: " + text);
+    }
+
     // Read the first non-header line of an NNTP article.
     private static String getFirstLine(FMSConnection connection, int articleId) throws IOException {
-        System.err.println("Trying to read article: " + articleId);
+        debug("Trying to read article: " + articleId);
         ArticleResponse response = connection.article(articleId);
 
         // MUST be on the first line of the message body.
@@ -117,7 +122,7 @@ public class FMSUtil {
             firstLine = line;
             break;
         }
-        System.err.println("GOT LINE: " + firstLine);
+        debug("GOT LINE: " + firstLine);
         while (reader.readLine() != null) { /* Must consume all data */ }
         return firstLine;
     }
@@ -203,7 +208,7 @@ public class FMSUtil {
             while(overviews.hasNext()) {
                 Overview overview = (Overview)(overviews.next());
                 for (int index = 0; index < 6; index++) {
-                    System.err.println(String.format("%d: [%s]", index, overview.getHeader(index).toString()));
+                    debug(String.format("%d: [%s]", index, overview.getHeader(index).toString()));
                 }
                 if (((String)overview.getHeader(4)).length() > 0) {
                     continue; // Skip replies.
@@ -289,7 +294,7 @@ public class FMSUtil {
                 String msg = String.format(MSG_TEMPLATE, user, group, SUBJECT_PREFIX + name,
                                            String.format("%s|%s", name, value));
                 byte[] rawBytes = msg.getBytes(UTF8);
-                System.err.println("Writing post bytes: " + rawBytes.length);
+                debug("Writing post bytes: " + rawBytes.length);
                 out.write(rawBytes);
                 out.flush();
             } finally {
diff --git a/readme.txt b/readme.txt
--- a/readme.txt
+++ b/readme.txt
@@ -91,8 +91,9 @@ BUG: MUST show in the UI when edited wik
 
 ---
 IDEA: shrink blocks by using a token map?  use short token in binary rep, fixup to full 20byte hash on read / write?
-IDEA: Support links to other wikis. e.g.:b fniki://fms/group/name
+*IDEA: Support links to other wikis. e.g.:b fniki://nntp/group/name[/SSK@some_version]
       [Not quite. Should be able to support mutliple transports (fms, ft, freemail?, fproxy usk?) in same url]
+      [See notes below on ft fms interop. one NNTP to rule them all]
 IDEA: Caching in FreenetIO
       Make LinkCache and interface
       FreenetLinkCache extends LinkCache
@@ -103,15 +104,21 @@ IDEA: Caching in FreenetIO
         FreenetTopKey getTopKey(ssk)
         SHA1 cacheBlock(CHK, InputStream)
         boolean isCached(CHK)
-IDEA: Pillage glog graph drawing code from hg to improve discover versions UI
+*IDEA: Pillage glog graph drawing code from hg to improve discover versions UI
       http://selenic.com/repo/hg-stable/file/4ec34de8bbb1/hgext/graphlog.py
 IDEA: Ditch human readable name <--> SSK fixup and generate arbitrary names from
       SSK public key hash (== big number). <n_first>*<m_middle>*<o_last> == big number
       let n = 1000, m = 1000,  o == 1000 => ???? [NOT QUITE. LOOK UP Combinatorics]
-IDEA: Staking.  add a biss message that says "I say this version is good"
+*IDEA: Staking.  add a biss message that says "I say this version is good"
       not "I published this version".  Add feedback in UI to show how many nyms staked a given version.
 IDEA: Wikibot ng. Just uses its FMS trust info to decide which version is the latest and
       send a "Stake" biss message for it.
+IDEA: Freetalk vs Freenet interop
+      0) Group naming convention. anythingbutmul.foo.bar.baz -> mul.anythingbutmul.foo.bar.baz in freetalk
+      1) fniki://groupname/wikiname -- same for both. Freetalk smtp code prefixes mul. to group
+      2) In config UI. add freetalk config and enable checkboxes for freeetalk and fms
+      3) Conventiton or config to choose which private key is used for SSK insertion.
+      Hmmm... not sure if people would use this feature because of the correlation of ids.
 ---
 Fixed bugs:
 2ce3a4499a2c: BUG: No way to create an empty wiki from the UI. [requested by a real user]
diff --git a/src/fniki/wiki/GraphLog.java b/src/fniki/wiki/GraphLog.java
new file mode 100644
--- /dev/null
+++ b/src/fniki/wiki/GraphLog.java
@@ -0,0 +1,853 @@
+/* Class to draw the revision graph.
+ *
+ * Changes Copyright (C) 2010, 2011 Darrell Karbott (see below).
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.0 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ * Author: djk@isFiaD04zgAgnrEC5XJt1i4IE7AkNPqhBG5bONi6Yks (changes)
+ *
+ *  This file was developed as component of
+ * "fniki" (a wiki implementation running over Freenet).
+ *
+ * This file is a derived work based on the graphlog.py
+ * from rev:643b8212813e in the mercurial hg-stable repo.
+ *
+ * sha1sum graphlog.py
+ * a0e599d4bd0b483025f0b5f182e737d8682a88ba  graphlog.py
+ *
+ * Original header:
+ * # ASCII graph log extension for Mercurial
+ * #
+ * # Copyright 2007 Joel Rosdahl <joel@rosdahl.net>
+ * #
+ * # This software may be used and distributed according to the terms of the
+ * # GNU General Public License version 2 or any later version.
+ *
+ */
+
+package fniki.wiki;
+
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.LineNumberReader;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+
+import java.util.Arrays;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class GraphLog {
+
+    ////////////////////////////////////////////////////////////
+    // A quick and dirty port of the first ~= 200 lines of
+    // graphlog.py from the mercurial codebase.
+    ////////////////////////////////////////////////////////////
+
+    public static ColumnData asciiedges(List<Integer> seen, int rev, List<Integer> parents) {
+        // """adds edge info to changelog DAG walk suitable for ascii()"""
+        if (!seen.contains(rev)) {
+            seen.add(rev);
+        }
+
+        int nodeidx = seen.indexOf(rev); // i.e. just looking up the index of the rev
+
+        // seen maps revs -> ordinals
+
+        List<Integer> known_parents = new ArrayList<Integer>();
+        List<Integer> new_parents = new ArrayList<Integer>();
+
+        for (int parent : parents) {
+            if (seen.contains(parent)) {
+                known_parents.add(parent);
+            } else {
+                new_parents.add(parent);
+            }
+        }
+
+        int ncols = seen.size();
+        seen.remove(nodeidx); // Correct? trying to get python slice assignement semantics
+        seen.addAll(nodeidx, new_parents);
+
+        List<AsciiEdge> edges = new ArrayList<AsciiEdge>();
+        for (int parent : known_parents) {
+            edges.add(new AsciiEdge(nodeidx, seen.indexOf(parent)));
+        }
+
+        if (new_parents.size() > 0) {
+            edges.add(new AsciiEdge(nodeidx, nodeidx));
+        }
+
+        if (new_parents.size() > 1) {
+            edges.add(new AsciiEdge(nodeidx, nodeidx + 1));
+        }
+
+        int nmorecols = seen.size() - ncols;
+        String edgeDump = "";
+        for (AsciiEdge edge : edges) {
+            edgeDump += String.format("(%d, %d), ", edge.mStart, edge.mEnd);
+        }
+        return new ColumnData(nodeidx, edges, ncols, nmorecols);
+    }
+
+    static void fix_long_right_edges(List<AsciiEdge> edges) {
+        int index = 0;
+        for (AsciiEdge edge : edges) {
+            if (edge.mEnd > edge.mStart) {
+                edges.set(index,  new AsciiEdge(edge.mStart, edge.mEnd + 1));
+            }
+            index++;
+        }
+    }
+
+    static List<String> get_nodeline_edges_tail(int node_index,
+                                             int p_node_index,
+                                             int n_columns,
+                                             int n_columns_diff,
+                                             int p_diff,
+                                             boolean fix_tail) {
+        if (fix_tail && n_columns_diff == p_diff && n_columns_diff != 0) {
+            //# Still going in the same non-vertical direction.
+            if (n_columns_diff == -1) {
+                int start = Math.max(node_index + 1, p_node_index);
+                List<String> tail = repeat(BAR_SPACE,  (start - node_index - 1));
+                tail.addAll(repeat(SLASH_SPACE, (n_columns - start)));
+                return tail;
+            } else {
+                return repeat(BACKSLASH_SPACE, (n_columns - node_index - 1));
+            }
+        } else {
+            return repeat(BAR_SPACE, (n_columns - node_index - 1));
+        }
+    }
+
+    static void draw_edges(List<AsciiEdge> edges, List<String> nodeline, List<String> interline) {
+        int index = 0;
+        for (AsciiEdge edge : edges) {
+            if (edge.mStart == edge.mEnd + 1) {
+                interline.set(2 * edge.mEnd + 1, "/");
+            } else if (edge.mStart == edge.mEnd - 1) {
+                interline.set(2 * edge.mStart + 1, "\\");
+            } else if (edge.mStart == edge.mEnd) {
+                interline.set(2 * edge.mStart, "|");
+            } else {
+                nodeline.set(2 * edge.mEnd, "+");
+                if (edge.mStart > edge.mEnd) {
+                    edges.set(index, new AsciiEdge(edge.mEnd, edge.mStart));
+                }
+
+                for (int j =  2 * edge.mStart + 1; j < 2 * edge.mEnd; j++) {
+                    if (!nodeline.get(j).equals("+")) {
+                        nodeline.set(j, "-");
+                    }
+                }
+            }
+
+            index++;
+        }
+    }
+
+    static List<String> get_padding_line(int ni, int n_columns, List<AsciiEdge> edges) {
+        List<String> line = new ArrayList<String>();
+        line.addAll(repeat(BAR_SPACE, ni));
+
+        String c = null;
+        //if (ni, ni - 1) in edges or (ni, ni) in edges:
+        if (edges.contains(new AsciiEdge(ni, ni - 1)) ||
+            edges.contains(new AsciiEdge(ni, ni))) {
+            //# (ni, ni - 1)      (ni, ni)
+            //# | | | |           | | | |
+            //# +---o |           | o---+
+            //# | | c |           | c | |
+            //# | |/ /            | |/ /
+            //# | | |             | | |
+            c = "|";
+        } else {
+            c = " ";
+        }
+        line.add(c);
+        line.add(" ");
+
+        line.addAll(repeat(BAR_SPACE, (n_columns - ni - 1)));
+        return line;
+    }
+
+    public static AsciiState asciistate() {
+        //"""returns the initial value for the "state" argument to ascii()"""
+        return new AsciiState(0, 0);
+    }
+
+    static boolean  should_add_padding_line(List<String> text, int coldiff, List<AsciiEdge> edges) {
+        //add_padding_line = (len(text) > 2 and coldiff == -1 and
+        //                [x for (x, y) in edges if x + 1 < y])
+        if (!(text.size() > 2 && coldiff == -1)) {
+            return false;
+        }
+        for (AsciiEdge edge : edges) {
+            if (edge.mStart + 1 < edge.mEnd) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // # Called once for each entry in the dag list
+    public static void ascii(Writer ui, AsciiState state, /* type, */
+                      String nodeChar, List<String> text,
+                      ColumnData coldata) throws IOException {
+        //"""prints an ASCII graph of the DAG
+        //
+        //takes the following arguments (one call per node in the graph):
+
+        //- ui to write to
+        //- Somewhere to keep the needed state in (init to asciistate())
+        //- Column of the current node in the set of ongoing edges.
+        //- Type indicator of node data == ASCIIDATA.
+        //- Payload: (char, lines):
+        //- Character to use as node's symbol.
+        //- List of lines to display as the node's text.
+        //- Edges; a list of (col, next_col) indicating the edges between
+        //  the current node and its parents.
+        //- Number of columns (ongoing edges) in the current revision.
+        //- The difference between the number of columns (ongoing edges)
+        //  in the next revision and the number of columns (ongoing edges)
+        //  in the current revision. That is: -1 means one column removed;
+        //  0 means no columns added or removed; 1 means one column added.
+        // """
+
+        int idx = coldata.mNodeIdx;
+        List<AsciiEdge> edges = coldata.mEdges;
+        int ncols = coldata.mNCols;
+        int coldiff = coldata.mMoreCols;
+
+        assert_(coldiff > -2  && coldiff < 2);
+
+        if (coldiff == -1) {
+            //# Transform
+            //#
+            //#     | | |        | | |
+            //#     o | |  into  o---+
+            //#     |X /         |/ /
+            //#     | |          | |
+            fix_long_right_edges(edges);
+        }
+
+        //# add_padding_line says whether to rewrite
+        //#
+        //#     | | | |        | | | |
+        //#     | o---+  into  | o---+
+        //#     |  / /         |   | |  # <--- padding line
+        //#     o | |          |  / /
+        //#                    o | |
+        //add_padding_line = (len(text) > 2 and coldiff == -1 and
+        //                [x for (x, y) in edges if x + 1 < y])
+
+        boolean add_padding_line = should_add_padding_line(text, coldiff, edges);
+
+        //# fix_nodeline_tail says whether to rewrite
+        //#
+        //#     | | o | |        | | o | |
+        //#     | | |/ /         | | |/ /
+        //#     | o | |    into  | o / /   # <--- fixed nodeline tail
+        //#     | |/ /           | |/ /
+        //#     o | |            o | |
+        boolean fix_nodeline_tail = (text.size() <= 2) && (!add_padding_line);
+
+        // # nodeline is the line containing the node character (typically o)
+        List<String> nodeline = repeat(BAR_SPACE, idx);
+        nodeline.add(nodeChar);
+        nodeline.add(" ");
+
+        nodeline.addAll(get_nodeline_edges_tail(idx, state.mIdx, ncols, coldiff,
+                                                state.mColDiff, fix_nodeline_tail));
+
+        //# shift_interline is the line containing the non-vertical
+        //# edges between this entry and the next
+        List<String> shift_interline = repeat(BAR_SPACE, idx);
+        String edge_ch = null;
+        int n_spaces = -1;
+        if (coldiff == -1) {
+            n_spaces = 1;
+            edge_ch = "/";
+        } else if (coldiff == 0) {
+            n_spaces = 2;
+            edge_ch = "|";
+        } else {
+            n_spaces = 3;
+            edge_ch = "\\";
+        }
+
+        shift_interline.addAll(repeat(ONE_SPACE, n_spaces));
+        List<String> valueToRepeat = new ArrayList<String>();
+        valueToRepeat.add(edge_ch);
+        valueToRepeat.add(" ");
+        shift_interline.addAll(repeat(valueToRepeat, (ncols - idx - 1)));
+
+        //# draw edges from the current node to its parents
+        draw_edges(edges, nodeline, shift_interline);
+
+        //# lines is the list of all graph lines to print
+        List<String> lines = new ArrayList<String>(Arrays.asList(list_to_string(nodeline)));
+        if (add_padding_line) {
+            lines.add(list_to_string(get_padding_line(idx, ncols, edges)));
+        }
+        lines.add(list_to_string(shift_interline));
+
+        //# make sure that there are as many graph lines as there are
+        //# log strings
+        while (text.size() < lines.size()) {
+            text.add("");
+        }
+
+        if (lines.size() < text.size()) {
+            String extra_interline = list_to_string(repeat(BAR_SPACE, (ncols + coldiff)));
+            while (lines.size() < text.size()) {
+                lines.add(extra_interline);
+            }
+        }
+        //# print lines
+        int indentation_level = Math.max(ncols, ncols + coldiff);
+        assert_(lines.size() == text.size());
+        int index = 0;
+        for (String line : lines) {
+            String logstr = text.get(index);
+            index++;
+            // "%-*s => means "left justify string with width specified in the tuple"
+            // ln = "%-*s %s" % (2 * indentation_level, "".join(line), logstr)
+            String fmt = "%-" + ("" + (2 * indentation_level)).trim() + "s %s";
+            ui.write(rstrip(String.format(fmt, line, logstr)));
+            ui.write("\n");
+        }
+
+        state.mColDiff = coldiff;
+        state.mIdx = idx;
+    }
+
+    ////////////////////////////////////////////////////////////
+    // Helper data structures and functions used to port
+    // to Java.
+    ////////////////////////////////////////////////////////////
+    static final class AsciiEdge {
+        public final int mStart;
+        public final int mEnd;
+        AsciiEdge(int start, int end) { mStart = start; mEnd = end; }
+    }
+
+    public static final class ColumnData {
+        public final int mNodeIdx;
+        public final int mNCols;
+        public final List<AsciiEdge> mEdges;
+        public final int mMoreCols;
+
+        public ColumnData(int nodeidx, List<AsciiEdge> edges, int ncols, int nmorecols) {
+            mNodeIdx = nodeidx;
+            mEdges = edges;
+            mNCols = ncols;
+            mMoreCols = nmorecols;
+        }
+    }
+
+    // NOT immutable. Hrmmm..
+    public static class AsciiState {
+        public int mColDiff;
+        public int mIdx;
+        public AsciiState(int coldiff, int idx) { mColDiff = coldiff; mIdx = idx; }
+    }
+
+    // A directed graph node.
+    public static class DAGNode {
+        public final String mTag;
+        public final int mId;
+        public final List<Integer> mParentIds;
+        DAGNode(String tag, int id, List<Integer> parentIds) {
+            mTag = tag;
+            mId = id;
+            mParentIds = parentIds;
+        }
+
+        public String asString() {
+            return "{" + mTag + ", " + mId + ", " + GraphLog.prettyString_(mParentIds) + "}";
+        }
+    }
+
+    final static List<String> BAR_SPACE =
+        Collections.unmodifiableList(Arrays.asList("|", " "));
+
+    final static List<String> SLASH_SPACE =
+        Collections.unmodifiableList(Arrays.asList("/", " "));
+
+    final static List<String> BACKSLASH_SPACE =
+        Collections.unmodifiableList(Arrays.asList("\\", " "));
+
+    final static List<String> ONE_SPACE =
+        Collections.unmodifiableList(Arrays.asList(" "));
+
+    static List<String> repeat(List<String> value, int count) {
+        List<String> ret = new ArrayList<String>();
+        while (count > 0) {
+            ret.addAll(value);
+            count--;
+        }
+        return ret;
+    }
+
+    static void assert_(boolean condition) {
+        if (condition) { return; }
+        throw new RuntimeException("Assertion Failure!");
+    }
+
+    // PARANOID: Can get rid of the explicit  iteration later if this is too slow.
+    static String list_to_string(List<String> values) {
+        StringBuilder sb = new StringBuilder();
+        for (String singleChar : values) {
+            assert_(singleChar.length() == 1);
+            sb.append(singleChar);
+        }
+        return sb.toString();
+    }
+
+    static String rstrip(String text) {
+        if (text.length() == 0) {
+            return "";
+        }
+        int pos = text.length() - 1;
+        while (pos >= 0 && text.charAt(pos) == ' ') {
+            pos--;
+        }
+        if (pos == text.length() - 1) {
+            return text;
+        }
+        return text.substring(0, pos + 1);
+    }
+
+    ////////////////////////////////////////////////////////////
+    // New code written on top of the ported mercurial stuff.
+    ////////////////////////////////////////////////////////////
+
+    public static class GraphEdge {
+        public final String mFrom;
+        public final String mTo;
+        public GraphEdge(String from, String to) { mFrom = from; mTo = to; }
+        public boolean equals(Object obj) {
+            if (obj == null || (!(obj instanceof GraphEdge))) {
+                return false;
+            }
+            GraphEdge other = (GraphEdge)obj;
+            return mFrom.equals(other.mFrom) && mTo.equals(other.mTo);
+        }
+        public int hashCode() { return (mFrom + mTo).hashCode(); } // Hmmm... fast enough?
+    }
+
+    static class ParentInfo {
+        public final Set<String> mAllParents;
+        public final Set<String> mUnseenParents;
+        public final Set<String> mRootIds;
+        public ParentInfo(Set<String> allParents,
+                          Set<String> unseenParents,
+                          Set<String> rootIds) {
+            mAllParents = allParents;
+            mUnseenParents = unseenParents;
+            mRootIds = rootIds;
+        }
+    }
+
+    static class OrdinalMapper {
+        private final Map<String, Integer> mLut = new HashMap<String, Integer>();
+        private int mCount;
+
+        public OrdinalMapper() {}
+
+        public int ordinal(String for_value) {
+            if (!mLut.keySet().contains(for_value)) {
+                mLut.put(for_value, mCount);
+                mCount++;
+            }
+            return mLut.get(for_value);
+        }
+        public void dump() {
+            List<String> keys = new ArrayList<String>(mLut.keySet());
+            Collections.sort(keys);
+            for (String key : keys) {
+                System.err.println(String.format("%s -> %d", key, mLut.get(key)));
+            }
+        }
+    }
+
+    static Set<String> allDescendants(Map<String, Set<String>> graph, String leaf_id) {
+        Set<String> seen = new HashSet<String>();
+        List<String> stack = new ArrayList<String>();
+        stack.add(leaf_id);
+
+        while (stack.size() > 0) {
+            String edge_id = stack.remove(stack.size() - 1);
+            if (seen.contains(edge_id)) {
+                continue;
+            }
+
+            seen.add(edge_id);
+            if (!graph.keySet().contains(edge_id)) {
+                continue;
+            }
+
+            for (String parent : graph.get(edge_id)) {
+                stack.add(parent);
+            }
+        }
+        return seen; // # Hmmmm... includes leaf_id
+    }
+
+    static List<Map<String, Set<String>>> find_connected_subgraphs(Map<String, Set<String>> graph,
+                                                                   Set<String> root_ids,
+                                                                   Set<String> leaf_node_ids) {
+        List<Set<String>> subgraph_id_sets = new ArrayList<Set<String>>();
+        for (String leaf_id : leaf_node_ids) {
+            subgraph_id_sets.add(allDescendants(graph, leaf_id));
+        }
+
+        //# Coalesce subgraphs which have common root nodes
+        for(String key : root_ids) {
+            Set<String> coalesced = new HashSet<String>();
+
+            // # Deep copy the list so I can mutate while iterating
+            List<Set<String>> copyForMutatingIteration = new ArrayList<Set<String>>();
+            copyForMutatingIteration.addAll(subgraph_id_sets);
+            for (Set<String> subgraph_id_set : copyForMutatingIteration) {
+                if (!subgraph_id_set.contains(key)) {
+                    continue;
+                }
+
+                coalesced.addAll(subgraph_id_set);
+                subgraph_id_sets.remove(subgraph_id_set);
+            }
+
+            if (coalesced.size() > 0) {
+                subgraph_id_sets.add(coalesced);
+            }
+
+            if (subgraph_id_sets.size() < 2) {
+                break; //# Bail out if there is only one graph
+            }
+        }
+
+        List<Map<String, Set<String>>> subgraphs = new ArrayList<Map<String, Set<String>>>();
+        for (Set<String> subgraph_id_set : subgraph_id_sets) {
+            Map<String, Set<String>>copy = new HashMap<String, Set<String>>();
+            for (String id_value : subgraph_id_set) {
+                if (graph.keySet().contains(id_value)) { // # handle unseen parents
+                    Set<String> deepCopy = new HashSet<String>();
+                    deepCopy.addAll(graph.get(id_value));
+                    copy.put(id_value, deepCopy);
+                }
+            }
+            //dumpGraph("subgraph", copy);
+            subgraphs.add(copy);
+        }
+        return subgraphs;
+    }
+
+    // graph is a map of node identifiers -> set of parent nodes.
+    public static Map<String, Set<String>> buildRawGraph(List<GraphEdge> edges) {
+        Map<String, Set<String>> graph = new HashMap<String, Set<String>>();
+        for (GraphEdge edge : edges) {
+            Set<String> parents = graph.get(edge.mTo);
+            if (parents == null) {
+                parents = new HashSet<String> ();
+                graph.put(edge.mTo, parents);
+            }
+            parents.add(edge.mFrom);
+        }
+        return graph;
+    }
+
+    // IMPORTANT: The "reversed" graph is what you need to traverse to build the DAG.
+    // Take a graph which maps node ids -> set of parents and create a graph
+    // which maps node ids -> set of children from it.
+    static Map<String, Set<String>> get_reversed_graph(Map<String, Set<String>> graph,
+                                                       Set<String> root_ids,
+                                                       OrdinalMapper ordinals) {
+        Map<String, Set<String>> reversed_graph = new HashMap<String, Set<String>>();
+
+        for(Map.Entry<String, Set<String>> entry : graph.entrySet()) {
+            String key = entry.getKey();
+            Set<String> parents = entry.getValue();
+
+            for (String parent : parents) {
+                if (!graph.keySet().contains(parent)) {
+                    continue;
+                }
+
+                if (!reversed_graph.keySet().contains(parent)) {
+                    reversed_graph.put(parent, new HashSet<String>());
+                }
+
+                //# i.e. is a child of the parent
+                reversed_graph.get(parent).add(key);
+            }
+
+            //# For leaf nodes.
+            if (!reversed_graph.keySet().contains(key)) {
+                reversed_graph.put(key, new HashSet<String>());
+            }
+        }
+        return reversed_graph;
+    }
+
+    public static ParentInfo getParentInfo(Map<String, Set<String>> graph) {
+        Set<String> allParents = new HashSet<String>();
+        for (Set<String> parents : graph.values()) {
+            allParents.addAll(parents);
+        }
+
+        Set<String> unseenParents = new HashSet<String>();
+        unseenParents.addAll(allParents);
+        unseenParents.removeAll(graph.keySet());
+
+        Set<String> rootIds = new HashSet<String>();
+
+        for(Map.Entry<String, Set<String>> entry : graph.entrySet()) {
+            Set<String> parents = entry.getValue();
+            if (parents == null || parents.size() == 0) {
+                rootIds.add(entry.getKey());
+                continue;
+            }
+
+            assert_(parents.size() > 0);
+
+            Set<String> unknownParents =  new HashSet<String>();
+
+            unknownParents.addAll(parents);
+            unknownParents.removeAll(unseenParents);
+
+            // BUG: ??? Revisit. what if only some parents are unknown?
+            //# All parents are unknown
+            if (unknownParents.size() == 0) {
+                rootIds.add(entry.getKey());
+            }
+        }
+
+        return new ParentInfo(allParents, unseenParents, rootIds);
+    }
+
+    // LATER: Grok harder. Possible to get rid of this?
+    //# Breadth first traversal of the graph to force creation of ordinals
+    //# in an order that won't freak out the drawing code.
+    static void traverse_in_plausible_commit_order(Map<String, Set<String>> reversed_graph,
+                                                   Set<String> root_ids,
+                                                   OrdinalMapper ordinals) {
+
+        LinkedList<String> queue = new LinkedList<String>();
+        queue.addAll(root_ids);
+        Collections.sort(queue);
+
+        //# Force creation of ordinals for root nodes
+        for (String id_value : queue) {
+            ordinals.ordinal(id_value);
+        }
+
+        Set<String> seen = new HashSet<String>();
+        while (queue.size() > 0) {
+            String id_value = queue.removeFirst();
+            if (seen.contains(id_value)) {
+                continue;
+            }
+            seen.add(id_value);
+
+            // Tricky: Sort to force creation of new ordinals in "natural order"
+            //         of child ids.
+            List<String> child_ids = new ArrayList<String>();
+            child_ids.addAll(reversed_graph.get(id_value));
+            Collections.sort(child_ids);
+
+            for (String child_id : child_ids) {
+                ordinals.ordinal(child_id);
+                queue.add(child_id);
+            }
+        }
+    }
+
+    // Create a list of graph nodes in the correct order so that
+    // they can be used to draw the graph with the ascii() function.
+    static List<DAGNode> build_dag(Map<String, Set<String>> graph, String null_rev_name) {
+        ParentInfo parentInfo = getParentInfo(graph);
+        Set<String> all_parents = parentInfo.mAllParents;
+        Set<String> unseen_parents = parentInfo.mUnseenParents;
+        Set<String> root_ids = parentInfo.mRootIds;
+
+        OrdinalMapper ordinals = new OrdinalMapper();
+
+        Map<String, Set<String>> reversed_graph = get_reversed_graph(graph, root_ids, ordinals);
+
+        //# Important: This sets the graph ordinals correctly.
+        traverse_in_plausible_commit_order(reversed_graph, root_ids, ordinals);
+
+        int null_rev_ordinal = ordinals.ordinal(null_rev_name);
+
+        LinkedList<String> queue = new LinkedList<String>();
+        queue.addAll(root_ids);
+        Collections.sort(queue);
+
+        List<DAGNode> dag = new ArrayList<DAGNode>();
+        Set<String> seen = new HashSet<String>();
+        while (queue.size() > 0) {
+            String id_value = queue.removeFirst();
+            if (seen.contains(id_value)) {
+                continue;
+            }
+            seen.add(id_value);
+
+            List<Integer> parents = new ArrayList<Integer>();
+            for (String parent_id : graph.get(id_value)) {
+                // This accesses ordinals in parent order, but the DAG requires them
+                // to be in child order.  That's why we need the call to
+                // traverse_in_plausible_commit_order above.
+                parents.add(ordinals.ordinal(parent_id));
+            }
+            Collections.sort(parents);
+
+            if (parents.size() == 1 && parents.get(0) == null_rev_ordinal) {
+                // Causes ascii() not to draw a line down from the 'o'.
+                parents = new ArrayList<Integer>();
+            }
+
+            dag.add(new DAGNode(id_value, ordinals.ordinal(id_value), parents));
+
+            if (!reversed_graph.keySet().contains(id_value)) {
+                continue;
+            }
+
+            // Tricky: Must traverse in order or the dag nodes won't be right.
+            List<String> child_ids = new ArrayList<String>();
+            child_ids.addAll(reversed_graph.get(id_value));
+            Collections.sort(child_ids);
+
+            for (String child_id : child_ids) {
+                queue.add(child_id);
+            }
+        }
+
+        Collections.reverse(dag);
+        return dag;
+    }
+
+    // BUG: detect cylces?
+    // REQUIRES: edges form an acyclic directed graph.
+    public static List<List<DAGNode>> build_dags(List<GraphEdge> edges, String null_rev_name) {
+        //""" Creates a list of DAG lists that can be printed with the ascii()
+        //    method,  from an unordered list of edges. """
+
+        Map<String, Set<String>> graph = buildRawGraph(edges);
+        ParentInfo parentInfo = getParentInfo(graph);
+        Set<String> all_parents = parentInfo.mAllParents;
+        Set<String> root_ids = parentInfo.mRootIds;
+
+        Set<String> leaf_node_ids = new HashSet<String>();
+        leaf_node_ids.addAll(graph.keySet());
+        leaf_node_ids.removeAll(all_parents);
+
+        List<Map<String, Set<String>>> subgraphs = find_connected_subgraphs(graph,
+                                                                            root_ids,
+                                                                            leaf_node_ids);
+        List<List<DAGNode>> dags = new ArrayList<List<DAGNode>>();
+        for (Map<String, Set<String>> subgraph : subgraphs) {
+            dags.add(build_dag(subgraph, null_rev_name));
+        }
+        return dags;
+    }
+
+    private final static String prettyString(Set<String> set) {
+        List<String> list = new ArrayList<String>();
+        list.addAll(set);
+        Collections.sort(list);
+        return prettyString(list);
+    }
+
+    private final static String prettyString(List<String> list) {
+        String ret = "{";
+        for (String value : list) {
+            ret += value + ", ";
+        }
+        ret = ret.trim() + "}";
+        return ret;
+    }
+
+    // GRRRR... Colliding type erasure. Java is a poor man's C++ :-(
+    static String prettyString_(List<Integer> values) {
+        List<String> list = new ArrayList<String>();
+        for (Integer value : values) {
+            list.add(value.toString());
+        }
+        return prettyString(list);
+    }
+
+    private final static void dumpGraph(String msg, Map<String, Set<String>> graph) {
+        System.err.println("--- graph: " + msg);
+        List<String> keys = new ArrayList<String>();
+        keys.addAll(graph.keySet());
+        Collections.sort(keys);
+
+        for (String key : keys) {
+            System.err.println(String.format("   [%s] -> [%s]", key, prettyString(graph.get(key))));
+        }
+    }
+    // File format:
+    // <child_rev> <parent_rev>
+    // one entry per line, lines starting with # and // are ignored.
+    private final static List<GraphEdge> readEdgesFromFile(String fileName) throws IOException {
+        List<GraphEdge> list = new ArrayList<GraphEdge>();
+
+        LineNumberReader reader = new LineNumberReader(new FileReader(fileName));
+        String line = reader.readLine();
+
+        while(line != null) {
+            if (line.trim().equals("") || line.startsWith("#") || line.startsWith("//")) {
+                line = reader.readLine();
+                continue;
+            }
+            String[] fields = line.trim().split(" ");
+            list.add(new GraphEdge(fields[1], fields[0]));
+            line = reader.readLine();
+        }
+        return list;
+    }
+
+    public static void test_dumping_graph(List<DAGNode> dag) throws IOException {
+        List<Integer> seen = new ArrayList<Integer>();
+        AsciiState state = asciistate();
+        Writer out = new OutputStreamWriter(System.out);
+        for (DAGNode value : dag) {
+            List<String> lines = new ArrayList<String>();
+            lines.add(String.format("id: %s (%d)", value.mTag, value.mId));
+            ascii(out, state, "o", lines, asciiedges(seen, value.mId, value.mParentIds));
+        }
+        out.flush();
+    }
+
+    public final static void main(String[] args) throws Exception {
+        System.out.println(String.format("Reading edges from: %s", args[0]));
+        List<GraphEdge> edges = readEdgesFromFile(args[0]);
+        List<List<DAGNode>> dags = build_dags(edges, "null_rev");
+
+        for (List<DAGNode> dag : dags) {
+            System.out.println("--- dag ---");
+            test_dumping_graph(dag);
+        }
+    }
+}
diff --git a/src/fniki/wiki/child/LoadingVersionList.java b/src/fniki/wiki/child/LoadingVersionList.java
--- a/src/fniki/wiki/child/LoadingVersionList.java
+++ b/src/fniki/wiki/child/LoadingVersionList.java
@@ -29,7 +29,16 @@ import java.io.PrintStream;
 import java.io.PrintWriter;
 import java.io.StringWriter;
 
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 import static ys.wikiparser.Utils.*;
 
@@ -40,6 +49,7 @@ import wormarc.FileManifest;
 import fniki.wiki.ArchiveManager;
 import fniki.wiki.ChildContainer;
 import fniki.wiki.ChildContainerException;
+import fniki.wiki.GraphLog;
 import static fniki.wiki.HtmlUtils.*;
 import fniki.wiki.WikiContext;
 
@@ -157,44 +167,174 @@ public class LoadingVersionList extends 
 
         fields = fields[1].split("_");
         if (fields.length != 2) { // LATER. handle multiple parents
+            if (fields.length == 1 && fields[0].length() == 16) {
+                // Assume the entry is the first version.
+                return "0000000000000000";
+            }
+
             return "???";
         }
 
         return fields[1];
     }
 
+    final static class DAGData implements Comparable<DAGData> {
+        public final int mSize;
+        public final long mEpochMs;
+        public final List<GraphLog.DAGNode> mDag;
+        DAGData(int size, long epochMs, List<GraphLog.DAGNode> dag) {
+            mSize = size;
+            mEpochMs = epochMs;
+            mDag = dag;
+        }
+
+        public int compareTo(DAGData o) { // DCI: test!
+            if (o == null) { throw new NullPointerException(); }
+            if (o == this) { return 0; }
+            if (o.mSize - mSize != 0) { // first by descending size.
+                return o.mSize - mSize;
+            }
+            if (o.mEpochMs - mEpochMs != 0) { // then by descending date.
+                return (o.mEpochMs - mEpochMs) > 0 ? 1: -1;
+            }
+            return 0;
+        }
+
+        // Hmmmm... not sure these are required.
+        public boolean equals(Object obj) {
+            if (obj == this) { return true; }
+
+            if (obj == null || (!(obj instanceof DAGData))) {
+                return false;
+            }
+
+            DAGData other = (DAGData)obj;
+            return mSize == other.mSize &&
+                mEpochMs == other.mEpochMs &&
+                mDag.equals(other.mDag);
+        }
+
+        public int hashCode() {
+            int result = 17;
+            result = 37 * mSize;
+            result = 37 * (int)(mEpochMs ^ (mEpochMs >>> 32));
+            result = 37 * mDag.hashCode();
+            return result;
+        }
+    }
+
+    // Wed, 02 Mar 11 02:57:38 -0000
+    private final static DateFormat sDateFormat = new java.text.SimpleDateFormat("EEE, dd MMM yy HH:mm:ssZ");
+    private static void sortBySizeAndDate(List<List<GraphLog.DAGNode>> dags, Map<String, List<FMSUtil.BISSRecord>> lut) {
+        List<DAGData> dagData = new ArrayList<DAGData>();
+        for (List<GraphLog.DAGNode> dag : dags) {
+            long epochMs = 0;
+            for (GraphLog.DAGNode node : dag) {
+                for (FMSUtil.BISSRecord record : lut.get(node.mTag)) {
+                    try {
+                        long zuluMs = sDateFormat.parse(record.mDate).getTime();
+                        if (zuluMs > epochMs) {
+                            epochMs = zuluMs;
+                        }
+                    } catch (ParseException pe) {
+                        System.err.println("Parse of date failed: " + record.mDate);
+                    }
+                }
+            }
+            dagData.add(new DAGData(dag.size(), epochMs, dag));
+        }
+        Collections.sort(dagData);
+        dags.clear();
+        for (DAGData data : dagData) {
+            dags.add(data.mDag);
+        }
+    }
+
+    public synchronized String getRevisionGraphHtml(List<FMSUtil.BISSRecord> records)
+        throws IOException {
+
+        // Build a list of revision graph edges from the NNTP notification records.
+        List<GraphLog.GraphEdge> edges = new ArrayList<GraphLog.GraphEdge>();
+        Map<String, List<FMSUtil.BISSRecord>> lut = new HashMap<String, List<FMSUtil.BISSRecord>>();
+        for (FMSUtil.BISSRecord record : records) {
+            String child = getVersionHex(record.mKey);
+            String parent = getParentVersion(record);
+            if (child.equals("???") || parent.equals("???")) {
+                System.err.println(String.format("Skipping: (%s, %s)", child, parent));
+                System.err.println("  " + record.mKey);
+                continue;
+            }
+
+            if (child.equals("0000000000000000")) { // DCI: srsly? use constant.
+                System.err.println(String.format("Attempted attack? Skipping: (%s, %s)", child, parent));
+                System.err.println("  " + record.mKey);
+                continue;
+            }
+
+            List<FMSUtil.BISSRecord> recordsEntry = lut.get(child);
+            if (recordsEntry == null) {
+                recordsEntry = new ArrayList<FMSUtil.BISSRecord>();
+                lut.put(child, recordsEntry);
+            }
+            if (!lut.get(child).contains(record)) {
+                lut.get(child).add(record);
+            }
+            GraphLog.GraphEdge edge = new GraphLog.GraphEdge(parent, child); // DCI: DOCUMENT ORDER
+            if (!edges.contains(edge)) { // hmmmm.... O(n) search.
+                edges.add(edge);
+            }
+        }
+
+        // Passing "0000000000000000" keep the drawing code from drawing '|'
+        // below root nodes.
+        List<List<GraphLog.DAGNode>> dags = GraphLog.build_dags(edges, "0000000000000000");
+        sortBySizeAndDate(dags, lut);
+
+        // Draw the revision graph(s).
+        StringWriter out = new StringWriter();
+        out.write("<pre>\n");
+        for (List<GraphLog.DAGNode> dag : dags) {
+            out.write("<hr>\n");
+            List<Integer> seen = new ArrayList<Integer>();
+            GraphLog.AsciiState state = GraphLog.asciistate();
+            for (GraphLog.DAGNode value : dag) {
+                List<FMSUtil.BISSRecord> references = lut.get(value.mTag);
+
+                List<String> lines = new ArrayList<String>();
+                String versionLink = getShortVersionLink(mContainerPrefix, "/jfniki/loadarchive",
+                                                         references.get(0).mKey); // All the same.
+
+                lines.add(versionLink);
+                for (FMSUtil.BISSRecord reference : references) {
+                    // DCI: Sort by date
+                    lines.add(String.format("user: %s (%s, %s, %s, %s)",
+                                            reference.mFmsId,
+                                            trustString(reference.msgTrust()),
+                                            trustString(reference.trustListTrust()),
+                                            trustString(reference.peerMsgTrust()),
+                                            trustString(reference.peerTrustListTrust())
+                                            ));
+                    lines.add(String.format("date: %s", reference.mDate)); // Reliable?
+                }
+                lines.add("");
+
+                GraphLog.ascii(out, state, "o", lines, GraphLog.asciiedges(seen, value.mId, value.mParentIds));
+            }
+        }
+        out.write("</pre>\n");
+        out.flush();
+        return out.toString();
+    }
+
     public boolean doWork(PrintStream out) throws Exception {
         synchronized (this) {
-            mListHtml = new StringBuilder();
+            mListHtml = new StringBuilder(); // DCI: why list html?
         }
         try {
-            out.println("Reading versions from FMS.");
-            List<FMSUtil.BISSRecord> records = mArchiveManager.getRecentWikiVersions(out);
-
+            out.println("Reading versions from FMS...");
+            String graphHtml = getRevisionGraphHtml(mArchiveManager.getRecentWikiVersions(out));
             synchronized (this) {
-
-                mListHtml.append("<table border=\"1\">\n");
-                mListHtml.append("<tr><td>FMS ID</td><td>Date</td><td>Version</td><td>Parent</td>" +
-                                 "<td>Msg Trust</td><td>TL Trust</td>" +
-                                 "<td>Peer Msg Trust</td><td>Peer TL Trust</td></tr>\n");
-
-                final String fmt = "<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td>" +
-                "<td>%s</td><td>%s</td></tr>\n";
-
-                // DCI: BUG. fix to force finished
-                for (FMSUtil.BISSRecord record : records)  {
-                    mListHtml.append(String.format(fmt,
-                                                   escapeHTML(record.mFmsId),
-                                                   escapeHTML(record.mDate),
-                                                   getShortVersionLink(mContainerPrefix,
-                                                                  "/jfniki/loadarchive", record.mKey),
-                                                   escapeHTML(getParentVersion(record)),
-                                                   trustString(record.msgTrust()),
-                                                   trustString(record.trustListTrust()),
-                                                   trustString(record.peerMsgTrust()),
-                                                   trustString(record.peerTrustListTrust())));
-                }
-                mListHtml.append("</table>\n");
+                mListHtml.append(graphHtml);
             }
             return true;
         } catch (IOException ioe) {