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

[tor-commits] [onionoo/master] Sort classes into six sub packages.



commit 43b563b5a0fe46cfaa899b67ca1fc6804166b468
Author: Karsten Loesing <karsten.loesing@xxxxxxx>
Date:   Wed Jul 23 17:34:36 2014 +0200

    Sort classes into six sub packages.
---
 build.xml                                          |    2 +-
 etc/web.xml.template                               |    4 +-
 src/org/torproject/onionoo/ApplicationFactory.java |   51 --
 src/org/torproject/onionoo/BandwidthDocument.java  |   27 -
 .../onionoo/BandwidthDocumentWriter.java           |  190 -----
 src/org/torproject/onionoo/BandwidthStatus.java    |   78 --
 .../torproject/onionoo/BandwidthStatusUpdater.java |  145 ----
 src/org/torproject/onionoo/ClientsDocument.java    |   22 -
 .../torproject/onionoo/ClientsDocumentWriter.java  |  284 --------
 .../torproject/onionoo/ClientsGraphHistory.java    |   83 ---
 src/org/torproject/onionoo/ClientsStatus.java      |  211 ------
 .../torproject/onionoo/ClientsStatusUpdater.java   |  224 ------
 src/org/torproject/onionoo/DateTimeHelper.java     |   92 ---
 src/org/torproject/onionoo/DescriptorSource.java   |  665 -----------------
 src/org/torproject/onionoo/DetailsDocument.java    |  365 ----------
 .../torproject/onionoo/DetailsDocumentWriter.java  |  222 ------
 src/org/torproject/onionoo/DetailsStatus.java      |  141 ----
 src/org/torproject/onionoo/Document.java           |   24 -
 src/org/torproject/onionoo/DocumentStore.java      |  744 -------------------
 src/org/torproject/onionoo/DocumentWriter.java     |   11 -
 src/org/torproject/onionoo/GraphHistory.java       |   56 --
 src/org/torproject/onionoo/LockFile.java           |   43 --
 src/org/torproject/onionoo/Logger.java             |   81 ---
 src/org/torproject/onionoo/LookupService.java      |  408 -----------
 src/org/torproject/onionoo/Main.java               |  119 ----
 .../onionoo/NodeDetailsStatusUpdater.java          |  620 ----------------
 src/org/torproject/onionoo/NodeIndexer.java        |  425 -----------
 src/org/torproject/onionoo/NodeStatus.java         |  581 ---------------
 src/org/torproject/onionoo/RequestHandler.java     |  548 --------------
 src/org/torproject/onionoo/ResourceServlet.java    |  448 ------------
 src/org/torproject/onionoo/ResponseBuilder.java    |  311 --------
 .../onionoo/ReverseDomainNameResolver.java         |  174 -----
 src/org/torproject/onionoo/StatusUpdater.java      |   11 -
 src/org/torproject/onionoo/SummaryDocument.java    |  201 ------
 .../torproject/onionoo/SummaryDocumentWriter.java  |   87 ---
 src/org/torproject/onionoo/Time.java               |   14 -
 src/org/torproject/onionoo/UpdateStatus.java       |    7 -
 src/org/torproject/onionoo/UptimeDocument.java     |   23 -
 .../torproject/onionoo/UptimeDocumentWriter.java   |  291 --------
 src/org/torproject/onionoo/UptimeStatus.java       |  226 ------
 .../torproject/onionoo/UptimeStatusUpdater.java    |  126 ----
 src/org/torproject/onionoo/WeightsDocument.java    |   64 --
 .../torproject/onionoo/WeightsDocumentWriter.java  |  222 ------
 src/org/torproject/onionoo/WeightsStatus.java      |   97 ---
 .../torproject/onionoo/WeightsStatusUpdater.java   |  328 ---------
 src/org/torproject/onionoo/cron/Main.java          |  140 ++++
 .../torproject/onionoo/docs/BandwidthDocument.java |   27 +
 .../torproject/onionoo/docs/BandwidthStatus.java   |   80 +++
 .../torproject/onionoo/docs/ClientsDocument.java   |   22 +
 .../onionoo/docs/ClientsGraphHistory.java          |   83 +++
 .../torproject/onionoo/docs/ClientsHistory.java    |  174 +++++
 src/org/torproject/onionoo/docs/ClientsStatus.java |   43 ++
 .../torproject/onionoo/docs/DetailsDocument.java   |  365 ++++++++++
 src/org/torproject/onionoo/docs/DetailsStatus.java |  141 ++++
 src/org/torproject/onionoo/docs/Document.java      |   24 +
 src/org/torproject/onionoo/docs/DocumentStore.java |  748 ++++++++++++++++++++
 src/org/torproject/onionoo/docs/GraphHistory.java  |   56 ++
 src/org/torproject/onionoo/docs/NodeStatus.java    |  582 +++++++++++++++
 .../torproject/onionoo/docs/SummaryDocument.java   |  202 ++++++
 src/org/torproject/onionoo/docs/UpdateStatus.java  |    7 +
 .../torproject/onionoo/docs/UptimeDocument.java    |   23 +
 src/org/torproject/onionoo/docs/UptimeHistory.java |   90 +++
 src/org/torproject/onionoo/docs/UptimeStatus.java  |  142 ++++
 .../torproject/onionoo/docs/WeightsDocument.java   |   64 ++
 src/org/torproject/onionoo/docs/WeightsStatus.java |   99 +++
 src/org/torproject/onionoo/server/NodeIndexer.java |  432 +++++++++++
 .../torproject/onionoo/server/RequestHandler.java  |  552 +++++++++++++++
 .../torproject/onionoo/server/ResourceServlet.java |  451 ++++++++++++
 .../torproject/onionoo/server/ResponseBuilder.java |  320 +++++++++
 .../onionoo/updater/BandwidthStatusUpdater.java    |  149 ++++
 .../onionoo/updater/ClientsStatusUpdater.java      |  230 ++++++
 .../onionoo/updater/DescriptorListener.java        |    7 +
 .../onionoo/updater/DescriptorSource.java          |  646 +++++++++++++++++
 .../torproject/onionoo/updater/DescriptorType.java |   15 +
 .../onionoo/updater/FingerprintListener.java       |   10 +
 .../torproject/onionoo/updater/LookupResult.java   |   70 ++
 .../torproject/onionoo/updater/LookupService.java  |  343 +++++++++
 .../onionoo/updater/NodeDetailsStatusUpdater.java  |  626 ++++++++++++++++
 .../onionoo/updater/ReverseDomainNameResolver.java |  179 +++++
 .../torproject/onionoo/updater/StatusUpdater.java  |   11 +
 .../onionoo/updater/UptimeStatusUpdater.java       |  130 ++++
 .../onionoo/updater/WeightsStatusUpdater.java      |  332 +++++++++
 .../onionoo/util/ApplicationFactory.java           |   55 ++
 .../torproject/onionoo/util/DateTimeHelper.java    |   92 +++
 src/org/torproject/onionoo/util/LockFile.java      |   43 ++
 src/org/torproject/onionoo/util/Logger.java        |   81 +++
 src/org/torproject/onionoo/util/Time.java          |   14 +
 .../onionoo/writer/BandwidthDocumentWriter.java    |  201 ++++++
 .../onionoo/writer/ClientsDocumentWriter.java      |  296 ++++++++
 .../onionoo/writer/DetailsDocumentWriter.java      |  233 ++++++
 .../torproject/onionoo/writer/DocumentWriter.java  |   11 +
 .../onionoo/writer/SummaryDocumentWriter.java      |   94 +++
 .../onionoo/writer/UptimeDocumentWriter.java       |  303 ++++++++
 .../onionoo/writer/WeightsDocumentWriter.java      |  233 ++++++
 .../torproject/onionoo/DummyDescriptorSource.java  |    4 +
 .../org/torproject/onionoo/DummyDocumentStore.java |    3 +
 test/org/torproject/onionoo/DummyTime.java         |    2 +
 test/org/torproject/onionoo/LookupServiceTest.java |    2 +
 .../torproject/onionoo/ResourceServletTest.java    |   44 +-
 .../onionoo/UptimeDocumentWriterTest.java          |    7 +
 test/org/torproject/onionoo/UptimeStatusTest.java  |    4 +
 .../onionoo/UptimeStatusUpdaterTest.java           |    6 +
 102 files changed, 9327 insertions(+), 9112 deletions(-)

diff --git a/build.xml b/build.xml
index dccf374..a69bac2 100644
--- a/build.xml
+++ b/build.xml
@@ -93,7 +93,7 @@
   <target name="run" depends="compile">
     <java fork="true"
           maxmemory="4g"
-          classname="org.torproject.onionoo.Main"
+          classname="org.torproject.onionoo.cron.Main"
           error="errors">
       <classpath refid="classpath"/>
     </java>
diff --git a/etc/web.xml.template b/etc/web.xml.template
index 988d47a..2c0b280 100644
--- a/etc/web.xml.template
+++ b/etc/web.xml.template
@@ -9,7 +9,7 @@
   <servlet>
     <servlet-name>Resource</servlet-name>
     <servlet-class>
-      org.torproject.onionoo.ResourceServlet
+      org.torproject.onionoo.server.ResourceServlet
     </servlet-class>
     <init-param>
       <param-name>maintenance</param-name>
@@ -48,7 +48,7 @@
 
   <listener>
     <listener-class>
-      org.torproject.onionoo.NodeIndexer
+      org.torproject.onionoo.server.NodeIndexer
     </listener-class>
   </listener>
 </web-app>
diff --git a/src/org/torproject/onionoo/ApplicationFactory.java b/src/org/torproject/onionoo/ApplicationFactory.java
deleted file mode 100644
index 44f2c17..0000000
--- a/src/org/torproject/onionoo/ApplicationFactory.java
+++ /dev/null
@@ -1,51 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-public class ApplicationFactory {
-
-  private static Time timeInstance;
-  public static void setTime(Time time) {
-    timeInstance = time;
-  }
-  public static Time getTime() {
-    if (timeInstance == null) {
-      timeInstance = new Time();
-    }
-    return timeInstance;
-  }
-
-  private static DescriptorSource descriptorSourceInstance;
-  public static void setDescriptorSource(
-      DescriptorSource descriptorSource) {
-    descriptorSourceInstance = descriptorSource;
-  }
-  public static DescriptorSource getDescriptorSource() {
-    if (descriptorSourceInstance == null) {
-      descriptorSourceInstance = new DescriptorSource();
-    }
-    return descriptorSourceInstance;
-  }
-
-  private static DocumentStore documentStoreInstance;
-  public static void setDocumentStore(DocumentStore documentStore) {
-    documentStoreInstance = documentStore;
-  }
-  public static DocumentStore getDocumentStore() {
-    if (documentStoreInstance == null) {
-      documentStoreInstance = new DocumentStore();
-    }
-    return documentStoreInstance;
-  }
-
-  private static NodeIndexer nodeIndexerInstance;
-  public static void setNodeIndexer(NodeIndexer nodeIndexer) {
-    nodeIndexerInstance = nodeIndexer;
-  }
-  public static NodeIndexer getNodeIndexer() {
-    if (nodeIndexerInstance == null) {
-      nodeIndexerInstance = new NodeIndexer();
-    }
-    return nodeIndexerInstance;
-  }
-}
diff --git a/src/org/torproject/onionoo/BandwidthDocument.java b/src/org/torproject/onionoo/BandwidthDocument.java
deleted file mode 100644
index 9c7cd4d..0000000
--- a/src/org/torproject/onionoo/BandwidthDocument.java
+++ /dev/null
@@ -1,27 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Map;
-
-public class BandwidthDocument extends Document {
-
-  @SuppressWarnings("unused")
-  private String fingerprint;
-  public void setFingerprint(String fingerprint) {
-    this.fingerprint = fingerprint;
-  }
-
-  @SuppressWarnings("unused")
-  private Map<String, GraphHistory> write_history;
-  public void setWriteHistory(Map<String, GraphHistory> writeHistory) {
-    this.write_history = writeHistory;
-  }
-
-  @SuppressWarnings("unused")
-  private Map<String, GraphHistory> read_history;
-  public void setReadHistory(Map<String, GraphHistory> readHistory) {
-    this.read_history = readHistory;
-  }
-}
-
diff --git a/src/org/torproject/onionoo/BandwidthDocumentWriter.java b/src/org/torproject/onionoo/BandwidthDocumentWriter.java
deleted file mode 100644
index 164ab30..0000000
--- a/src/org/torproject/onionoo/BandwidthDocumentWriter.java
+++ /dev/null
@@ -1,190 +0,0 @@
-/* Copyright 2011--2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-
-public class BandwidthDocumentWriter implements FingerprintListener,
-    DocumentWriter{
-
-  private DescriptorSource descriptorSource;
-
-  private DocumentStore documentStore;
-
-  private long now;
-
-  public BandwidthDocumentWriter() {
-    this.descriptorSource = ApplicationFactory.getDescriptorSource();
-    this.documentStore = ApplicationFactory.getDocumentStore();
-    this.now = ApplicationFactory.getTime().currentTimeMillis();
-    this.registerFingerprintListeners();
-  }
-
-  private void registerFingerprintListeners() {
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.RELAY_EXTRA_INFOS);
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.BRIDGE_EXTRA_INFOS);
-  }
-
-  private Set<String> updateBandwidthDocuments = new HashSet<String>();
-
-  public void processFingerprints(SortedSet<String> fingerprints,
-      boolean relay) {
-    this.updateBandwidthDocuments.addAll(fingerprints);
-  }
-
-  public void writeDocuments() {
-    for (String fingerprint : this.updateBandwidthDocuments) {
-      BandwidthStatus bandwidthStatus = this.documentStore.retrieve(
-          BandwidthStatus.class, true, fingerprint);
-      if (bandwidthStatus == null) {
-        continue;
-      }
-      BandwidthDocument bandwidthDocument = this.compileBandwidthDocument(
-          fingerprint, bandwidthStatus);
-      this.documentStore.store(bandwidthDocument, fingerprint);
-    }
-    Logger.printStatusTime("Wrote bandwidth document files");
-  }
-
-
-  private BandwidthDocument compileBandwidthDocument(String fingerprint,
-      BandwidthStatus bandwidthStatus) {
-    BandwidthDocument bandwidthDocument = new BandwidthDocument();
-    bandwidthDocument.setFingerprint(fingerprint);
-    bandwidthDocument.setWriteHistory(this.compileGraphType(
-        bandwidthStatus.getWriteHistory()));
-    bandwidthDocument.setReadHistory(this.compileGraphType(
-        bandwidthStatus.getReadHistory()));
-    return bandwidthDocument;
-  }
-
-  private String[] graphNames = new String[] {
-      "3_days",
-      "1_week",
-      "1_month",
-      "3_months",
-      "1_year",
-      "5_years" };
-
-  private long[] graphIntervals = new long[] {
-      DateTimeHelper.THREE_DAYS,
-      DateTimeHelper.ONE_WEEK,
-      DateTimeHelper.ROUGHLY_ONE_MONTH,
-      DateTimeHelper.ROUGHLY_THREE_MONTHS,
-      DateTimeHelper.ROUGHLY_ONE_YEAR,
-      DateTimeHelper.ROUGHLY_FIVE_YEARS };
-
-  private long[] dataPointIntervals = new long[] {
-      DateTimeHelper.FIFTEEN_MINUTES,
-      DateTimeHelper.ONE_HOUR,
-      DateTimeHelper.FOUR_HOURS,
-      DateTimeHelper.TWELVE_HOURS,
-      DateTimeHelper.TWO_DAYS,
-      DateTimeHelper.TEN_DAYS };
-
-  private Map<String, GraphHistory> compileGraphType(
-      SortedMap<Long, long[]> history) {
-    Map<String, GraphHistory> graphs =
-        new LinkedHashMap<String, GraphHistory>();
-    for (int i = 0; i < this.graphIntervals.length; i++) {
-      String graphName = this.graphNames[i];
-      long graphInterval = this.graphIntervals[i];
-      long dataPointInterval = this.dataPointIntervals[i];
-      List<Long> dataPoints = new ArrayList<Long>();
-      long intervalStartMillis = ((this.now - graphInterval)
-          / dataPointInterval) * dataPointInterval;
-      long totalMillis = 0L, totalBandwidth = 0L;
-      for (long[] v : history.values()) {
-        long startMillis = v[0], endMillis = v[1], bandwidth = v[2];
-        if (endMillis < intervalStartMillis) {
-          continue;
-        }
-        while ((intervalStartMillis / dataPointInterval) !=
-            (endMillis / dataPointInterval)) {
-          dataPoints.add(totalMillis * 5L < dataPointInterval
-              ? -1L : (totalBandwidth * DateTimeHelper.ONE_SECOND)
-              / totalMillis);
-          totalBandwidth = 0L;
-          totalMillis = 0L;
-          intervalStartMillis += dataPointInterval;
-        }
-        totalBandwidth += bandwidth;
-        totalMillis += (endMillis - startMillis);
-      }
-      dataPoints.add(totalMillis * 5L < dataPointInterval
-          ? -1L : (totalBandwidth * DateTimeHelper.ONE_SECOND)
-          / totalMillis);
-      long maxValue = 1L;
-      int firstNonNullIndex = -1, lastNonNullIndex = -1;
-      for (int j = 0; j < dataPoints.size(); j++) {
-        long dataPoint = dataPoints.get(j);
-        if (dataPoint >= 0L) {
-          if (firstNonNullIndex < 0) {
-            firstNonNullIndex = j;
-          }
-          lastNonNullIndex = j;
-          if (dataPoint > maxValue) {
-            maxValue = dataPoint;
-          }
-        }
-      }
-      if (firstNonNullIndex < 0) {
-        continue;
-      }
-      long firstDataPointMillis = (((this.now - graphInterval)
-          / dataPointInterval) + firstNonNullIndex) * dataPointInterval
-          + dataPointInterval / 2L;
-      if (i > 0 &&
-          firstDataPointMillis >= this.now - graphIntervals[i - 1]) {
-        /* Skip bandwidth history object, because it doesn't contain
-         * anything new that wasn't already contained in the last
-         * bandwidth history object(s). */
-        continue;
-      }
-      long lastDataPointMillis = firstDataPointMillis
-          + (lastNonNullIndex - firstNonNullIndex) * dataPointInterval;
-      double factor = ((double) maxValue) / 999.0;
-      int count = lastNonNullIndex - firstNonNullIndex + 1;
-      GraphHistory graphHistory = new GraphHistory();
-      graphHistory.setFirst(DateTimeHelper.format(firstDataPointMillis));
-      graphHistory.setLast(DateTimeHelper.format(lastDataPointMillis));
-      graphHistory.setInterval((int) (dataPointInterval
-          / DateTimeHelper.ONE_SECOND));
-      graphHistory.setFactor(factor);
-      graphHistory.setCount(count);
-      int previousNonNullIndex = -2;
-      boolean foundTwoAdjacentDataPoints = false;
-      List<Integer> values = new ArrayList<Integer>();
-      for (int j = firstNonNullIndex; j <= lastNonNullIndex; j++) {
-        long dataPoint = dataPoints.get(j);
-        if (dataPoint >= 0L) {
-          if (j - previousNonNullIndex == 1) {
-            foundTwoAdjacentDataPoints = true;
-          }
-          previousNonNullIndex = j;
-        }
-        values.add(dataPoint < 0L ? null :
-          (int) ((dataPoint * 999L) / maxValue));
-      }
-      graphHistory.setValues(values);
-      if (foundTwoAdjacentDataPoints) {
-        graphs.put(graphName, graphHistory);
-      }
-    }
-    return graphs;
-  }
-
-  public String getStatsString() {
-    /* TODO Add statistics string. */
-    return null;
-  }
-}
diff --git a/src/org/torproject/onionoo/BandwidthStatus.java b/src/org/torproject/onionoo/BandwidthStatus.java
deleted file mode 100644
index 3252d67..0000000
--- a/src/org/torproject/onionoo/BandwidthStatus.java
+++ /dev/null
@@ -1,78 +0,0 @@
-/* Copyright 2013--2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Scanner;
-import java.util.SortedMap;
-import java.util.TreeMap;
-
-public class BandwidthStatus extends Document {
-
-  private SortedMap<Long, long[]> writeHistory =
-      new TreeMap<Long, long[]>();
-  public void setWriteHistory(SortedMap<Long, long[]> writeHistory) {
-    this.writeHistory = writeHistory;
-  }
-  public SortedMap<Long, long[]> getWriteHistory() {
-    return this.writeHistory;
-  }
-
-  private SortedMap<Long, long[]> readHistory =
-      new TreeMap<Long, long[]>();
-  public void setReadHistory(SortedMap<Long, long[]> readHistory) {
-    this.readHistory = readHistory;
-  }
-  public SortedMap<Long, long[]> getReadHistory() {
-    return this.readHistory;
-  }
-
-  public void fromDocumentString(String documentString) {
-    Scanner s = new Scanner(documentString);
-    while (s.hasNextLine()) {
-      String line = s.nextLine();
-      String[] parts = line.split(" ");
-      if (parts.length != 6) {
-        System.err.println("Illegal line '" + line + "' in bandwidth "
-            + "history.  Skipping this line.");
-        continue;
-      }
-      SortedMap<Long, long[]> history = parts[0].equals("r")
-          ? readHistory : writeHistory;
-      long startMillis = DateTimeHelper.parse(parts[1] + " " + parts[2]);
-      long endMillis = DateTimeHelper.parse(parts[3] + " " + parts[4]);
-      if (startMillis < 0L || endMillis < 0L) {
-        System.err.println("Could not parse timestamp while reading "
-            + "bandwidth history.  Skipping.");
-        break;
-      }
-      long bandwidth = Long.parseLong(parts[5]);
-      long previousEndMillis = history.headMap(startMillis).isEmpty()
-          ? startMillis
-          : history.get(history.headMap(startMillis).lastKey())[1];
-      long nextStartMillis = history.tailMap(startMillis).isEmpty()
-          ? endMillis : history.tailMap(startMillis).firstKey();
-      if (previousEndMillis <= startMillis &&
-          nextStartMillis >= endMillis) {
-        history.put(startMillis, new long[] { startMillis, endMillis,
-            bandwidth });
-      }
-    }
-    s.close();
-  }
-
-  public String toDocumentString() {
-    StringBuilder sb = new StringBuilder();
-    for (long[] v : writeHistory.values()) {
-      sb.append("w " + DateTimeHelper.format(v[0]) + " "
-          + DateTimeHelper.format(v[1]) + " " + String.valueOf(v[2])
-          + "\n");
-    }
-    for (long[] v : readHistory.values()) {
-      sb.append("r " + DateTimeHelper.format(v[0]) + " "
-          + DateTimeHelper.format(v[1]) + " " + String.valueOf(v[2])
-          + "\n");
-    }
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/BandwidthStatusUpdater.java b/src/org/torproject/onionoo/BandwidthStatusUpdater.java
deleted file mode 100644
index 6320f6e..0000000
--- a/src/org/torproject/onionoo/BandwidthStatusUpdater.java
+++ /dev/null
@@ -1,145 +0,0 @@
-/* Copyright 2011--2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.SortedMap;
-import java.util.TreeMap;
-
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.ExtraInfoDescriptor;
-
-public class BandwidthStatusUpdater implements DescriptorListener,
-    StatusUpdater {
-
-  private DescriptorSource descriptorSource;
-
-  private DocumentStore documentStore;
-
-  private long now;
-
-  public BandwidthStatusUpdater() {
-    this.descriptorSource = ApplicationFactory.getDescriptorSource();
-    this.documentStore = ApplicationFactory.getDocumentStore();
-    this.now = ApplicationFactory.getTime().currentTimeMillis();
-    this.registerDescriptorListeners();
-  }
-
-  private void registerDescriptorListeners() {
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.RELAY_EXTRA_INFOS);
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.BRIDGE_EXTRA_INFOS);
-  }
-
-  public void processDescriptor(Descriptor descriptor, boolean relay) {
-    if (descriptor instanceof ExtraInfoDescriptor) {
-      this.parseDescriptor((ExtraInfoDescriptor) descriptor);
-    }
-  }
-
-  public void updateStatuses() {
-    /* Status files are already updated while processing descriptors. */
-  }
-
-  private void parseDescriptor(ExtraInfoDescriptor descriptor) {
-    String fingerprint = descriptor.getFingerprint();
-    BandwidthStatus bandwidthStatus = this.documentStore.retrieve(
-        BandwidthStatus.class, true, fingerprint);
-    if (bandwidthStatus == null) {
-      bandwidthStatus = new BandwidthStatus();
-    }
-    if (descriptor.getWriteHistory() != null) {
-      parseHistoryLine(descriptor.getWriteHistory().getLine(),
-          bandwidthStatus.getWriteHistory());
-    }
-    if (descriptor.getReadHistory() != null) {
-      parseHistoryLine(descriptor.getReadHistory().getLine(),
-          bandwidthStatus.getReadHistory());
-    }
-    this.compressHistory(bandwidthStatus.getWriteHistory());
-    this.compressHistory(bandwidthStatus.getReadHistory());
-    this.documentStore.store(bandwidthStatus, fingerprint);
-  }
-
-  private void parseHistoryLine(String line,
-      SortedMap<Long, long[]> history) {
-    String[] parts = line.split(" ");
-    if (parts.length < 6) {
-      return;
-    }
-    long endMillis = DateTimeHelper.parse(parts[1] + " " + parts[2]);
-    if (endMillis < 0L) {
-      System.err.println("Could not parse timestamp in line '" + line
-          + "'.  Skipping.");
-      return;
-    }
-    long intervalMillis = Long.parseLong(parts[3].substring(1))
-        * DateTimeHelper.ONE_SECOND;
-    String[] values = parts[5].split(",");
-    for (int i = values.length - 1; i >= 0; i--) {
-      long bandwidthValue = Long.parseLong(values[i]);
-      long startMillis = endMillis - intervalMillis;
-      /* TODO Should we first check whether an interval is already
-       * contained in history? */
-      history.put(startMillis, new long[] { startMillis, endMillis,
-          bandwidthValue });
-      endMillis -= intervalMillis;
-    }
-  }
-
-  private void compressHistory(SortedMap<Long, long[]> history) {
-    SortedMap<Long, long[]> uncompressedHistory =
-        new TreeMap<Long, long[]>(history);
-    history.clear();
-    long lastStartMillis = 0L, lastEndMillis = 0L, lastBandwidth = 0L;
-    String lastMonthString = "1970-01";
-    for (long[] v : uncompressedHistory.values()) {
-      long startMillis = v[0], endMillis = v[1], bandwidth = v[2];
-      long intervalLengthMillis;
-      if (this.now - endMillis <= DateTimeHelper.THREE_DAYS) {
-        intervalLengthMillis = DateTimeHelper.FIFTEEN_MINUTES;
-      } else if (this.now - endMillis <= DateTimeHelper.ONE_WEEK) {
-        intervalLengthMillis = DateTimeHelper.ONE_HOUR;
-      } else if (this.now - endMillis <=
-          DateTimeHelper.ROUGHLY_ONE_MONTH) {
-        intervalLengthMillis = DateTimeHelper.FOUR_HOURS;
-      } else if (this.now - endMillis <=
-          DateTimeHelper.ROUGHLY_THREE_MONTHS) {
-        intervalLengthMillis = DateTimeHelper.TWELVE_HOURS;
-      } else if (this.now - endMillis <=
-          DateTimeHelper.ROUGHLY_ONE_YEAR) {
-        intervalLengthMillis = DateTimeHelper.TWO_DAYS;
-      } else {
-        intervalLengthMillis = DateTimeHelper.TEN_DAYS;
-      }
-      String monthString = DateTimeHelper.format(startMillis,
-          DateTimeHelper.ISO_YEARMONTH_FORMAT);
-      if (lastEndMillis == startMillis &&
-          ((lastEndMillis - 1L) / intervalLengthMillis) ==
-          ((endMillis - 1L) / intervalLengthMillis) &&
-          lastMonthString.equals(monthString)) {
-        lastEndMillis = endMillis;
-        lastBandwidth += bandwidth;
-      } else {
-        if (lastStartMillis > 0L) {
-          history.put(lastStartMillis, new long[] { lastStartMillis,
-              lastEndMillis, lastBandwidth });
-        }
-        lastStartMillis = startMillis;
-        lastEndMillis = endMillis;
-        lastBandwidth = bandwidth;
-      }
-      lastMonthString = monthString;
-    }
-    if (lastStartMillis > 0L) {
-      history.put(lastStartMillis, new long[] { lastStartMillis,
-          lastEndMillis, lastBandwidth });
-    }
-  }
-
-  public String getStatsString() {
-    /* TODO Add statistics string. */
-    return null;
-  }
-}
-
diff --git a/src/org/torproject/onionoo/ClientsDocument.java b/src/org/torproject/onionoo/ClientsDocument.java
deleted file mode 100644
index e8e40ee..0000000
--- a/src/org/torproject/onionoo/ClientsDocument.java
+++ /dev/null
@@ -1,22 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Map;
-
-public class ClientsDocument extends Document {
-
-  @SuppressWarnings("unused")
-  private String fingerprint;
-  public void setFingerprint(String fingerprint) {
-    this.fingerprint = fingerprint;
-  }
-
-  @SuppressWarnings("unused")
-  private Map<String, ClientsGraphHistory> average_clients;
-  public void setAverageClients(
-      Map<String, ClientsGraphHistory> averageClients) {
-    this.average_clients = averageClients;
-  }
-}
-
diff --git a/src/org/torproject/onionoo/ClientsDocumentWriter.java b/src/org/torproject/onionoo/ClientsDocumentWriter.java
deleted file mode 100644
index 1fced6d..0000000
--- a/src/org/torproject/onionoo/ClientsDocumentWriter.java
+++ /dev/null
@@ -1,284 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-/*
- * Clients status file produced as intermediate output:
- *
- * 2014-02-15 16:42:11 2014-02-16 00:00:00
- *   259.042 in=86.347,se=86.347  v4=259.042
- * 2014-02-16 00:00:00 2014-02-16 16:42:11
- *   592.958 in=197.653,se=197.653  v4=592.958
- *
- * Clients document file produced as output:
- *
- * "1_month":{
- *   "first":"2014-02-03 12:00:00",
- *   "last":"2014-02-28 12:00:00",
- *   "interval":86400,
- *   "factor":0.139049349,
- *   "count":26,
- *   "values":[371,354,349,374,432,null,485,458,493,536,null,null,524,576,
- *             607,622,null,635,null,566,774,999,945,690,656,681],
- *   "countries":{"cn":0.0192,"in":0.1768,"ir":0.2487,"ru":0.0104,
- *                "se":0.1698,"sy":0.0325,"us":0.0406},
- *   "transports":{"obfs2":0.4581},
- *   "versions":{"v4":1.0000}}
- */
-public class ClientsDocumentWriter implements FingerprintListener,
-    DocumentWriter {
-
-  private DescriptorSource descriptorSource;
-
-  private DocumentStore documentStore;
-
-  private long now;
-
-  public ClientsDocumentWriter() {
-    this.descriptorSource = ApplicationFactory.getDescriptorSource();
-    this.documentStore = ApplicationFactory.getDocumentStore();
-    this.now = ApplicationFactory.getTime().currentTimeMillis();
-    this.registerFingerprintListeners();
-  }
-
-  private void registerFingerprintListeners() {
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.BRIDGE_EXTRA_INFOS);
-  }
-
-  private SortedSet<String> updateDocuments = new TreeSet<String>();
-
-  public void processFingerprints(SortedSet<String> fingerprints,
-      boolean relay) {
-    if (!relay) {
-      this.updateDocuments.addAll(fingerprints);
-    }
-  }
-
-  private int writtenDocuments = 0;
-
-  public void writeDocuments() {
-    for (String hashedFingerprint : this.updateDocuments) {
-      ClientsStatus clientsStatus = this.documentStore.retrieve(
-          ClientsStatus.class, true, hashedFingerprint);
-      if (clientsStatus == null) {
-        continue;
-      }
-      SortedSet<ClientsHistory> history = clientsStatus.getHistory();
-      ClientsDocument clientsDocument = this.compileClientsDocument(
-          hashedFingerprint, history);
-      this.documentStore.store(clientsDocument, hashedFingerprint);
-      this.writtenDocuments++;
-    }
-    Logger.printStatusTime("Wrote clients document files");
-  }
-
-  private String[] graphNames = new String[] {
-      "1_week",
-      "1_month",
-      "3_months",
-      "1_year",
-      "5_years" };
-
-  private long[] graphIntervals = new long[] {
-      DateTimeHelper.ONE_WEEK,
-      DateTimeHelper.ROUGHLY_ONE_MONTH,
-      DateTimeHelper.ROUGHLY_THREE_MONTHS,
-      DateTimeHelper.ROUGHLY_ONE_YEAR,
-      DateTimeHelper.ROUGHLY_FIVE_YEARS };
-
-  private long[] dataPointIntervals = new long[] {
-      DateTimeHelper.ONE_DAY,
-      DateTimeHelper.ONE_DAY,
-      DateTimeHelper.ONE_DAY,
-      DateTimeHelper.TWO_DAYS,
-      DateTimeHelper.TEN_DAYS };
-
-  private ClientsDocument compileClientsDocument(String hashedFingerprint,
-      SortedSet<ClientsHistory> history) {
-    ClientsDocument clientsDocument = new ClientsDocument();
-    clientsDocument.setFingerprint(hashedFingerprint);
-    Map<String, ClientsGraphHistory> averageClients =
-        new LinkedHashMap<String, ClientsGraphHistory>();
-    for (int graphIntervalIndex = 0; graphIntervalIndex <
-        this.graphIntervals.length; graphIntervalIndex++) {
-      String graphName = this.graphNames[graphIntervalIndex];
-      ClientsGraphHistory graphHistory = this.compileClientsHistory(
-          graphIntervalIndex, history);
-      if (graphHistory != null) {
-        averageClients.put(graphName, graphHistory);
-      }
-    }
-    clientsDocument.setAverageClients(averageClients);
-    return clientsDocument;
-  }
-
-  private ClientsGraphHistory compileClientsHistory(
-      int graphIntervalIndex, SortedSet<ClientsHistory> history) {
-    long graphInterval = this.graphIntervals[graphIntervalIndex];
-    long dataPointInterval =
-        this.dataPointIntervals[graphIntervalIndex];
-    List<Double> dataPoints = new ArrayList<Double>();
-    long intervalStartMillis = ((this.now - graphInterval)
-        / dataPointInterval) * dataPointInterval;
-    long millis = 0L;
-    double responses = 0.0, totalResponses = 0.0;
-    SortedMap<String, Double>
-        totalResponsesByCountry = new TreeMap<String, Double>(),
-        totalResponsesByTransport = new TreeMap<String, Double>(),
-        totalResponsesByVersion = new TreeMap<String, Double>();
-    for (ClientsHistory hist : history) {
-      if (hist.getEndMillis() < intervalStartMillis) {
-        continue;
-      }
-      while ((intervalStartMillis / dataPointInterval) !=
-          (hist.getEndMillis() / dataPointInterval)) {
-        dataPoints.add(millis * 2L < dataPointInterval
-            ? -1.0 : responses * ((double) DateTimeHelper.ONE_DAY)
-            / (((double) millis) * 10.0));
-        responses = 0.0;
-        millis = 0L;
-        intervalStartMillis += dataPointInterval;
-      }
-      responses += hist.getTotalResponses();
-      totalResponses += hist.getTotalResponses();
-      for (Map.Entry<String, Double> e :
-          hist.getResponsesByCountry().entrySet()) {
-        if (!totalResponsesByCountry.containsKey(e.getKey())) {
-          totalResponsesByCountry.put(e.getKey(), 0.0);
-        }
-        totalResponsesByCountry.put(e.getKey(), e.getValue()
-            + totalResponsesByCountry.get(e.getKey()));
-      }
-      for (Map.Entry<String, Double> e :
-          hist.getResponsesByTransport().entrySet()) {
-        if (!totalResponsesByTransport.containsKey(e.getKey())) {
-          totalResponsesByTransport.put(e.getKey(), 0.0);
-        }
-        totalResponsesByTransport.put(e.getKey(), e.getValue()
-            + totalResponsesByTransport.get(e.getKey()));
-      }
-      for (Map.Entry<String, Double> e :
-          hist.getResponsesByVersion().entrySet()) {
-        if (!totalResponsesByVersion.containsKey(e.getKey())) {
-          totalResponsesByVersion.put(e.getKey(), 0.0);
-        }
-        totalResponsesByVersion.put(e.getKey(), e.getValue()
-            + totalResponsesByVersion.get(e.getKey()));
-      }
-      millis += (hist.getEndMillis() - hist.getStartMillis());
-    }
-    dataPoints.add(millis * 2L < dataPointInterval
-        ? -1.0 : responses * ((double) DateTimeHelper.ONE_DAY)
-        / (((double) millis) * 10.0));
-    double maxValue = 0.0;
-    int firstNonNullIndex = -1, lastNonNullIndex = -1;
-    for (int dataPointIndex = 0; dataPointIndex < dataPoints.size();
-        dataPointIndex++) {
-      double dataPoint = dataPoints.get(dataPointIndex);
-      if (dataPoint >= 0.0) {
-        if (firstNonNullIndex < 0) {
-          firstNonNullIndex = dataPointIndex;
-        }
-        lastNonNullIndex = dataPointIndex;
-        if (dataPoint > maxValue) {
-          maxValue = dataPoint;
-        }
-      }
-    }
-    if (firstNonNullIndex < 0) {
-      return null;
-    }
-    long firstDataPointMillis = (((this.now - graphInterval)
-        / dataPointInterval) + firstNonNullIndex) * dataPointInterval
-        + dataPointInterval / 2L;
-    if (graphIntervalIndex > 0 && firstDataPointMillis >=
-        this.now - graphIntervals[graphIntervalIndex - 1]) {
-      /* Skip clients history object, because it doesn't contain
-       * anything new that wasn't already contained in the last
-       * clients history object(s). */
-      return null;
-    }
-    long lastDataPointMillis = firstDataPointMillis
-        + (lastNonNullIndex - firstNonNullIndex) * dataPointInterval;
-    double factor = ((double) maxValue) / 999.0;
-    int count = lastNonNullIndex - firstNonNullIndex + 1;
-    ClientsGraphHistory graphHistory = new ClientsGraphHistory();
-    graphHistory.setFirst(DateTimeHelper.format(firstDataPointMillis));
-    graphHistory.setLast(DateTimeHelper.format(lastDataPointMillis));
-    graphHistory.setInterval((int) (dataPointInterval
-        / DateTimeHelper.ONE_SECOND));
-    graphHistory.setFactor(factor);
-    graphHistory.setCount(count);
-    int previousNonNullIndex = -2;
-    boolean foundTwoAdjacentDataPoints = false;
-    List<Integer> values = new ArrayList<Integer>();
-    for (int dataPointIndex = firstNonNullIndex; dataPointIndex <=
-        lastNonNullIndex; dataPointIndex++) {
-      double dataPoint = dataPoints.get(dataPointIndex);
-      if (dataPoint >= 0.0) {
-        if (dataPointIndex - previousNonNullIndex == 1) {
-          foundTwoAdjacentDataPoints = true;
-        }
-        previousNonNullIndex = dataPointIndex;
-      }
-      values.add(dataPoint < 0.0 ? null :
-          (int) ((dataPoint * 999.0) / maxValue));
-    }
-    graphHistory.setValues(values);
-    if (!totalResponsesByCountry.isEmpty()) {
-      SortedMap<String, Float> countries = new TreeMap<String, Float>();
-      for (Map.Entry<String, Double> e :
-          totalResponsesByCountry.entrySet()) {
-        if (e.getValue() > totalResponses / 100.0) {
-          countries.put(e.getKey(),
-              (float) (e.getValue() / totalResponses));
-        }
-      }
-      graphHistory.setCountries(countries);
-    }
-    if (!totalResponsesByTransport.isEmpty()) {
-      SortedMap<String, Float> transports = new TreeMap<String, Float>();
-      for (Map.Entry<String, Double> e :
-          totalResponsesByTransport.entrySet()) {
-        if (e.getValue() > totalResponses / 100.0) {
-          transports.put(e.getKey(),
-              (float) (e.getValue() / totalResponses));
-        }
-      }
-      graphHistory.setTransports(transports);
-    }
-    if (!totalResponsesByVersion.isEmpty()) {
-      SortedMap<String, Float> versions = new TreeMap<String, Float>();
-      for (Map.Entry<String, Double> e :
-          totalResponsesByVersion.entrySet()) {
-        if (e.getValue() > totalResponses / 100.0) {
-          versions.put(e.getKey(),
-              (float) (e.getValue() / totalResponses));
-        }
-      }
-      graphHistory.setVersions(versions);
-    }
-    if (foundTwoAdjacentDataPoints) {
-      return graphHistory;
-    } else {
-      return null;
-    }
-  }
-
-  public String getStatsString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append("    " + Logger.formatDecimalNumber(this.writtenDocuments)
-        + " clients document files updated\n");
-    return sb.toString();
-  }
-}
diff --git a/src/org/torproject/onionoo/ClientsGraphHistory.java b/src/org/torproject/onionoo/ClientsGraphHistory.java
deleted file mode 100644
index b7b312b..0000000
--- a/src/org/torproject/onionoo/ClientsGraphHistory.java
+++ /dev/null
@@ -1,83 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.SortedMap;
-
-public class ClientsGraphHistory {
-
-  private String first;
-  public void setFirst(String first) {
-    this.first = first;
-  }
-  public String getFirst() {
-    return this.first;
-  }
-
-  private String last;
-  public void setLast(String last) {
-    this.last = last;
-  }
-  public String getLast() {
-    return this.last;
-  }
-
-  private Integer interval;
-  public void setInterval(Integer interval) {
-    this.interval = interval;
-  }
-  public Integer getInterval() {
-    return this.interval;
-  }
-
-  private Double factor;
-  public void setFactor(Double factor) {
-    this.factor = factor;
-  }
-  public Double getFactor() {
-    return this.factor;
-  }
-
-  private Integer count;
-  public void setCount(Integer count) {
-    this.count = count;
-  }
-  public Integer getCount() {
-    return this.count;
-  }
-
-  private List<Integer> values = new ArrayList<Integer>();
-  public void setValues(List<Integer> values) {
-    this.values = values;
-  }
-  public List<Integer> getValues() {
-    return this.values;
-  }
-
-  private SortedMap<String, Float> countries;
-  public void setCountries(SortedMap<String, Float> countries) {
-    this.countries = countries;
-  }
-  public SortedMap<String, Float> getCountries() {
-    return this.countries;
-  }
-
-  private SortedMap<String, Float> transports;
-  public void setTransports(SortedMap<String, Float> transports) {
-    this.transports = transports;
-  }
-  public SortedMap<String, Float> getTransports() {
-    return this.transports;
-  }
-
-  private SortedMap<String, Float> versions;
-  public void setVersions(SortedMap<String, Float> versions) {
-    this.versions = versions;
-  }
-  public SortedMap<String, Float> getVersions() {
-    return this.versions;
-  }
-}
-
diff --git a/src/org/torproject/onionoo/ClientsStatus.java b/src/org/torproject/onionoo/ClientsStatus.java
deleted file mode 100644
index 1ea16a7..0000000
--- a/src/org/torproject/onionoo/ClientsStatus.java
+++ /dev/null
@@ -1,211 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Map;
-import java.util.Scanner;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-class ClientsHistory implements Comparable<ClientsHistory> {
-
-  private long startMillis;
-  public long getStartMillis() {
-    return this.startMillis;
-  }
-
-  private long endMillis;
-  public long getEndMillis() {
-    return this.endMillis;
-  }
-
-  private double totalResponses;
-  public double getTotalResponses() {
-    return this.totalResponses;
-  }
-
-  private SortedMap<String, Double> responsesByCountry;
-  public SortedMap<String, Double> getResponsesByCountry() {
-    return this.responsesByCountry;
-  }
-
-  private SortedMap<String, Double> responsesByTransport;
-  public SortedMap<String, Double> getResponsesByTransport() {
-    return this.responsesByTransport;
-  }
-
-  private SortedMap<String, Double> responsesByVersion;
-  public SortedMap<String, Double> getResponsesByVersion() {
-    return this.responsesByVersion;
-  }
-
-  ClientsHistory(long startMillis, long endMillis,
-      double totalResponses,
-      SortedMap<String, Double> responsesByCountry,
-      SortedMap<String, Double> responsesByTransport,
-      SortedMap<String, Double> responsesByVersion) {
-    this.startMillis = startMillis;
-    this.endMillis = endMillis;
-    this.totalResponses = totalResponses;
-    this.responsesByCountry = responsesByCountry;
-    this.responsesByTransport = responsesByTransport;
-    this.responsesByVersion = responsesByVersion;
-  }
-
-  public static ClientsHistory fromString(
-      String responseHistoryString) {
-    String[] parts = responseHistoryString.split(" ", 8);
-    if (parts.length != 8) {
-      return null;
-    }
-    long startMillis = DateTimeHelper.parse(parts[0] + " " + parts[1]);
-    long endMillis = DateTimeHelper.parse(parts[2] + " " + parts[3]);
-    if (startMillis < 0L || endMillis < 0L) {
-      return null;
-    }
-    if (startMillis >= endMillis) {
-      return null;
-    }
-    double totalResponses = 0.0;
-    try {
-      totalResponses = Double.parseDouble(parts[4]);
-    } catch (NumberFormatException e) {
-      return null;
-    }
-    SortedMap<String, Double> responsesByCountry =
-        parseResponses(parts[5]);
-    SortedMap<String, Double> responsesByTransport =
-        parseResponses(parts[6]);
-    SortedMap<String, Double> responsesByVersion =
-        parseResponses(parts[7]);
-    if (responsesByCountry == null || responsesByTransport == null ||
-        responsesByVersion == null) {
-      return null;
-    }
-    return new ClientsHistory(startMillis, endMillis, totalResponses,
-        responsesByCountry, responsesByTransport, responsesByVersion);
-  }
-
-  private static SortedMap<String, Double> parseResponses(
-      String responsesString) {
-    SortedMap<String, Double> responses = new TreeMap<String, Double>();
-    if (responsesString.length() > 0) {
-      for (String pair : responsesString.split(",")) {
-        String[] keyValue = pair.split("=");
-        if (keyValue.length != 2) {
-          return null;
-        }
-        double value = 0.0;
-        try {
-          value = Double.parseDouble(keyValue[1]);
-        } catch (NumberFormatException e) {
-          return null;
-        }
-        responses.put(keyValue[0], value);
-      }
-    }
-    return responses;
-  }
-
-  public String toString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append(DateTimeHelper.format(startMillis));
-    sb.append(" " + DateTimeHelper.format(endMillis));
-    sb.append(" " + String.format("%.3f", this.totalResponses));
-    this.appendResponses(sb, this.responsesByCountry);
-    this.appendResponses(sb, this.responsesByTransport);
-    this.appendResponses(sb, this.responsesByVersion);
-    return sb.toString();
-  }
-
-  private void appendResponses(StringBuilder sb,
-      SortedMap<String, Double> responses) {
-    sb.append(" ");
-    int written = 0;
-    for (Map.Entry<String, Double> e : responses.entrySet()) {
-      sb.append((written++ > 0 ? "," : "") + e.getKey() + "="
-          + String.format("%.3f", e.getValue()));
-    }
-  }
-
-  public void addResponses(ClientsHistory other) {
-    this.totalResponses += other.totalResponses;
-    this.addResponsesByCategory(this.responsesByCountry,
-        other.responsesByCountry);
-    this.addResponsesByCategory(this.responsesByTransport,
-        other.responsesByTransport);
-    this.addResponsesByCategory(this.responsesByVersion,
-        other.responsesByVersion);
-    if (this.startMillis > other.startMillis) {
-      this.startMillis = other.startMillis;
-    }
-    if (this.endMillis < other.endMillis) {
-      this.endMillis = other.endMillis;
-    }
-  }
-
-  private void addResponsesByCategory(
-      SortedMap<String, Double> thisResponses,
-      SortedMap<String, Double> otherResponses) {
-    for (Map.Entry<String, Double> e : otherResponses.entrySet()) {
-      if (thisResponses.containsKey(e.getKey())) {
-        thisResponses.put(e.getKey(), thisResponses.get(e.getKey())
-            + e.getValue());
-      } else {
-        thisResponses.put(e.getKey(), e.getValue());
-      }
-    }
-  }
-
-  public int compareTo(ClientsHistory other) {
-    return this.startMillis < other.startMillis ? -1 :
-        this.startMillis > other.startMillis ? 1 : 0;
-  }
-
-  public boolean equals(Object other) {
-    return other instanceof ClientsHistory &&
-        this.startMillis == ((ClientsHistory) other).startMillis;
-  }
-
-  public int hashCode() {
-    return (int) this.startMillis;
-  }
-}
-
-public class ClientsStatus extends Document {
-
-  private SortedSet<ClientsHistory> history =
-      new TreeSet<ClientsHistory>();
-  public void setHistory(SortedSet<ClientsHistory> history) {
-    this.history = history;
-  }
-  public SortedSet<ClientsHistory> getHistory() {
-    return this.history;
-  }
-
-  public void fromDocumentString(String documentString) {
-    Scanner s = new Scanner(documentString);
-    while (s.hasNextLine()) {
-      String line = s.nextLine();
-      ClientsHistory parsedLine = ClientsHistory.fromString(line);
-      if (parsedLine != null) {
-        this.history.add(parsedLine);
-      } else {
-        System.err.println("Could not parse clients history line '"
-            + line + "'.  Skipping.");
-      }
-    }
-    s.close();
-  }
-
-  public String toDocumentString() {
-    StringBuilder sb = new StringBuilder();
-    for (ClientsHistory interval : this.history) {
-      sb.append(interval.toString() + "\n");
-    }
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/ClientsStatusUpdater.java b/src/org/torproject/onionoo/ClientsStatusUpdater.java
deleted file mode 100644
index 4fe2b2d..0000000
--- a/src/org/torproject/onionoo/ClientsStatusUpdater.java
+++ /dev/null
@@ -1,224 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.ExtraInfoDescriptor;
-
-/*
- * Example extra-info descriptor used as input:
- *
- * extra-info ndnop2 DE6397A047ABE5F78B4C87AF725047831B221AAB
- * dirreq-stats-end 2014-02-16 16:42:11 (86400 s)
- * dirreq-v3-resp ok=856,not-enough-sigs=0,unavailable=0,not-found=0,
- *   not-modified=40,busy=0
- * bridge-stats-end 2014-02-16 16:42:17 (86400 s)
- * bridge-ips ??=8,in=8,se=8
- * bridge-ip-versions v4=8,v6=0
- *
- * Clients status file produced as intermediate output:
- *
- * 2014-02-15 16:42:11 2014-02-16 00:00:00
- *   259.042 in=86.347,se=86.347  v4=259.042
- * 2014-02-16 00:00:00 2014-02-16 16:42:11
- *   592.958 in=197.653,se=197.653  v4=592.958
- */
-public class ClientsStatusUpdater implements DescriptorListener,
-    StatusUpdater {
-
-  private DescriptorSource descriptorSource;
-
-  private DocumentStore documentStore;
-
-  private long now;
-
-  public ClientsStatusUpdater() {
-    this.descriptorSource = ApplicationFactory.getDescriptorSource();
-    this.documentStore = ApplicationFactory.getDocumentStore();
-    this.now = ApplicationFactory.getTime().currentTimeMillis();
-    this.registerDescriptorListeners();
-  }
-
-  private void registerDescriptorListeners() {
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.BRIDGE_EXTRA_INFOS);
-  }
-
-  public void processDescriptor(Descriptor descriptor, boolean relay) {
-    if (descriptor instanceof ExtraInfoDescriptor && !relay) {
-      this.processBridgeExtraInfoDescriptor(
-          (ExtraInfoDescriptor) descriptor);
-    }
-  }
-
-  private SortedMap<String, SortedSet<ClientsHistory>> newResponses =
-      new TreeMap<String, SortedSet<ClientsHistory>>();
-
-  private void processBridgeExtraInfoDescriptor(
-      ExtraInfoDescriptor descriptor) {
-    long dirreqStatsEndMillis = descriptor.getDirreqStatsEndMillis();
-    long dirreqStatsIntervalLengthMillis =
-        descriptor.getDirreqStatsIntervalLength()
-        * DateTimeHelper.ONE_SECOND;
-    SortedMap<String, Integer> responses = descriptor.getDirreqV3Resp();
-    if (dirreqStatsEndMillis < 0L ||
-        dirreqStatsIntervalLengthMillis != DateTimeHelper.ONE_DAY ||
-        responses == null || !responses.containsKey("ok")) {
-      return;
-    }
-    double okResponses = (double) (responses.get("ok") - 4);
-    if (okResponses < 0.0) {
-      return;
-    }
-    String hashedFingerprint = descriptor.getFingerprint().toUpperCase();
-    long dirreqStatsStartMillis = dirreqStatsEndMillis
-        - dirreqStatsIntervalLengthMillis;
-    long utcBreakMillis = (dirreqStatsEndMillis / DateTimeHelper.ONE_DAY)
-        * DateTimeHelper.ONE_DAY;
-    for (int i = 0; i < 2; i++) {
-      long startMillis = i == 0 ? dirreqStatsStartMillis : utcBreakMillis;
-      long endMillis = i == 0 ? utcBreakMillis : dirreqStatsEndMillis;
-      if (startMillis >= endMillis) {
-        continue;
-      }
-      double totalResponses = okResponses
-          * ((double) (endMillis - startMillis))
-          / ((double) DateTimeHelper.ONE_DAY);
-      SortedMap<String, Double> responsesByCountry =
-          this.weightResponsesWithUniqueIps(totalResponses,
-          descriptor.getBridgeIps(), "??");
-      SortedMap<String, Double> responsesByTransport =
-          this.weightResponsesWithUniqueIps(totalResponses,
-          descriptor.getBridgeIpTransports(), "<??>");
-      SortedMap<String, Double> responsesByVersion =
-          this.weightResponsesWithUniqueIps(totalResponses,
-          descriptor.getBridgeIpVersions(), "");
-      ClientsHistory newResponseHistory = new ClientsHistory(
-          startMillis, endMillis, totalResponses, responsesByCountry,
-          responsesByTransport, responsesByVersion); 
-      if (!this.newResponses.containsKey(hashedFingerprint)) {
-        this.newResponses.put(hashedFingerprint,
-            new TreeSet<ClientsHistory>());
-      }
-      this.newResponses.get(hashedFingerprint).add(
-          newResponseHistory);
-    }
-  }
-
-  private SortedMap<String, Double> weightResponsesWithUniqueIps(
-      double totalResponses, SortedMap<String, Integer> uniqueIps,
-      String omitString) {
-    SortedMap<String, Double> weightedResponses =
-        new TreeMap<String, Double>();
-    int totalUniqueIps = 0;
-    if (uniqueIps != null) {
-      for (Map.Entry<String, Integer> e : uniqueIps.entrySet()) {
-        if (e.getValue() > 4) {
-          totalUniqueIps += e.getValue() - 4;
-        }
-      }
-    }
-    if (totalUniqueIps > 0) {
-      for (Map.Entry<String, Integer> e : uniqueIps.entrySet()) {
-        if (!e.getKey().equals(omitString) && e.getValue() > 4) {
-          weightedResponses.put(e.getKey(),
-              (((double) (e.getValue() - 4)) * totalResponses)
-              / ((double) totalUniqueIps));
-        }
-      }
-    }
-    return weightedResponses;
-  }
-
-  public void updateStatuses() {
-    for (Map.Entry<String, SortedSet<ClientsHistory>> e :
-        this.newResponses.entrySet()) {
-      String hashedFingerprint = e.getKey();
-      ClientsStatus clientsStatus = this.documentStore.retrieve(
-          ClientsStatus.class, true, hashedFingerprint);
-      if (clientsStatus == null) {
-        clientsStatus = new ClientsStatus();
-      }
-      this.addToHistory(clientsStatus, e.getValue());
-      this.compressHistory(clientsStatus);
-      this.documentStore.store(clientsStatus, hashedFingerprint);
-    }
-  }
-
-  private void addToHistory(ClientsStatus clientsStatus,
-      SortedSet<ClientsHistory> newIntervals) {
-    SortedSet<ClientsHistory> history = clientsStatus.getHistory();
-    for (ClientsHistory interval : newIntervals) {
-      if ((history.headSet(interval).isEmpty() ||
-          history.headSet(interval).last().getEndMillis() <=
-          interval.getStartMillis()) &&
-          (history.tailSet(interval).isEmpty() ||
-          history.tailSet(interval).first().getStartMillis() >=
-          interval.getEndMillis())) {
-        history.add(interval);
-      }
-    }
-  }
-
-  private void compressHistory(ClientsStatus clientsStatus) {
-    SortedSet<ClientsHistory> history = clientsStatus.getHistory();
-    SortedSet<ClientsHistory> compressedHistory =
-        new TreeSet<ClientsHistory>();
-    ClientsHistory lastResponses = null;
-    String lastMonthString = "1970-01";
-    for (ClientsHistory responses : history) {
-      long intervalLengthMillis;
-      if (this.now - responses.getEndMillis() <=
-          DateTimeHelper.ROUGHLY_THREE_MONTHS) {
-        intervalLengthMillis = DateTimeHelper.ONE_DAY;
-      } else if (this.now - responses.getEndMillis() <=
-          DateTimeHelper.ROUGHLY_ONE_YEAR) {
-        intervalLengthMillis = DateTimeHelper.TWO_DAYS;
-      } else {
-        intervalLengthMillis = DateTimeHelper.TEN_DAYS;
-      }
-      String monthString = DateTimeHelper.format(
-          responses.getStartMillis(),
-          DateTimeHelper.ISO_YEARMONTH_FORMAT);
-      if (lastResponses != null &&
-          lastResponses.getEndMillis() == responses.getStartMillis() &&
-          ((lastResponses.getEndMillis() - 1L) / intervalLengthMillis) ==
-          ((responses.getEndMillis() - 1L) / intervalLengthMillis) &&
-          lastMonthString.equals(monthString)) {
-        lastResponses.addResponses(responses);
-      } else {
-        if (lastResponses != null) {
-          compressedHistory.add(lastResponses);
-        }
-        lastResponses = responses;
-      }
-      lastMonthString = monthString;
-    }
-    if (lastResponses != null) {
-      compressedHistory.add(lastResponses);
-    }
-    clientsStatus.setHistory(compressedHistory);
-  }
-
-  public String getStatsString() {
-    int newIntervals = 0;
-    for (SortedSet<ClientsHistory> hist : this.newResponses.values()) {
-      newIntervals += hist.size();
-    }
-    StringBuilder sb = new StringBuilder();
-    sb.append("    "
-        + Logger.formatDecimalNumber(newIntervals / 2)
-        + " client statistics processed from extra-info descriptors\n");
-    sb.append("    "
-        + Logger.formatDecimalNumber(this.newResponses.size())
-        + " client status files updated\n");
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/DateTimeHelper.java b/src/org/torproject/onionoo/DateTimeHelper.java
deleted file mode 100644
index 41ac70b..0000000
--- a/src/org/torproject/onionoo/DateTimeHelper.java
+++ /dev/null
@@ -1,92 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.text.DateFormat;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.TimeZone;
-
-public class DateTimeHelper {
-
-  private DateTimeHelper() {
-  }
-
-  public static final long ONE_SECOND = 1000L,
-      TEN_SECONDS = 10L * ONE_SECOND,
-      ONE_MINUTE = 60L * ONE_SECOND,
-      FIVE_MINUTES = 5L * ONE_MINUTE,
-      FIFTEEN_MINUTES = 15L * ONE_MINUTE,
-      ONE_HOUR = 60L * ONE_MINUTE,
-      FOUR_HOURS = 4L * ONE_HOUR,
-      SIX_HOURS = 6L * ONE_HOUR,
-      TWELVE_HOURS = 12L * ONE_HOUR,
-      ONE_DAY = 24L * ONE_HOUR,
-      TWO_DAYS = 2L * ONE_DAY,
-      THREE_DAYS = 3L * ONE_DAY,
-      ONE_WEEK = 7L * ONE_DAY,
-      TEN_DAYS = 10L * ONE_DAY,
-      ROUGHLY_ONE_MONTH = 31L * ONE_DAY,
-      ROUGHLY_THREE_MONTHS = 92L * ONE_DAY,
-      ROUGHLY_ONE_YEAR = 366L * ONE_DAY,
-      ROUGHLY_FIVE_YEARS = 5L * ROUGHLY_ONE_YEAR;
-
-  public static final String ISO_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
-
-  public static final String ISO_DATETIME_TAB_FORMAT =
-      "yyyy-MM-dd\tHH:mm:ss";
-
-  public static final String ISO_YEARMONTH_FORMAT = "yyyy-MM";
-
-  public static final String DATEHOUR_NOSPACE_FORMAT = "yyyy-MM-dd-HH";
-
-  private static ThreadLocal<Map<String, DateFormat>> dateFormats =
-      new ThreadLocal<Map<String, DateFormat>> () {
-    public Map<String, DateFormat> get() {
-      return super.get();
-    }
-    protected Map<String, DateFormat> initialValue() {
-      return new HashMap<String, DateFormat>();
-    }
-    public void remove() {
-      super.remove();
-    }
-    public void set(Map<String, DateFormat> value) {
-      super.set(value);
-    }
-  };
-
-  private static DateFormat getDateFormat(String format) {
-    Map<String, DateFormat> threadDateFormats = dateFormats.get();
-    if (!threadDateFormats.containsKey(format)) {
-      DateFormat dateFormat = new SimpleDateFormat(format);
-      dateFormat.setLenient(false);
-      dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-      threadDateFormats.put(format, dateFormat);
-    }
-    return threadDateFormats.get(format);
-  }
-
-  public static String format(long millis, String format) {
-    return getDateFormat(format).format(millis);
-  }
-
-  public static String format(long millis) {
-    return format(millis, ISO_DATETIME_FORMAT);
-  }
-
-  public static long parse(String string, String format) {
-    try {
-      return getDateFormat(format).parse(string).getTime();
-    } catch (ParseException e) {
-      return -1L;
-    }
-  }
-
-  public static long parse(String string) {
-    return parse(string, ISO_DATETIME_FORMAT);
-  }
-}
-
diff --git a/src/org/torproject/onionoo/DescriptorSource.java b/src/org/torproject/onionoo/DescriptorSource.java
deleted file mode 100644
index 8246bba..0000000
--- a/src/org/torproject/onionoo/DescriptorSource.java
+++ /dev/null
@@ -1,665 +0,0 @@
-/* Copyright 2013, 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.io.BufferedInputStream;
-import java.io.BufferedOutputStream;
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.FileReader;
-import java.io.FileWriter;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.net.HttpURLConnection;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-import java.util.zip.GZIPInputStream;
-
-import org.torproject.descriptor.BridgeNetworkStatus;
-import org.torproject.descriptor.BridgePoolAssignment;
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.DescriptorFile;
-import org.torproject.descriptor.DescriptorReader;
-import org.torproject.descriptor.DescriptorSourceFactory;
-import org.torproject.descriptor.ExitList;
-import org.torproject.descriptor.ExitListEntry;
-import org.torproject.descriptor.ExtraInfoDescriptor;
-import org.torproject.descriptor.RelayNetworkStatusConsensus;
-import org.torproject.descriptor.ServerDescriptor;
-
-enum DescriptorType {
-  RELAY_CONSENSUSES,
-  RELAY_SERVER_DESCRIPTORS,
-  RELAY_EXTRA_INFOS,
-  EXIT_LISTS,
-  BRIDGE_STATUSES,
-  BRIDGE_SERVER_DESCRIPTORS,
-  BRIDGE_EXTRA_INFOS,
-  BRIDGE_POOL_ASSIGNMENTS,
-}
-
-interface DescriptorListener {
-  abstract void processDescriptor(Descriptor descriptor, boolean relay);
-}
-
-interface FingerprintListener {
-  abstract void processFingerprints(SortedSet<String> fingerprints,
-      boolean relay);
-}
-
-enum DescriptorHistory {
-  RELAY_CONSENSUS_HISTORY,
-  RELAY_SERVER_HISTORY,
-  RELAY_EXTRAINFO_HISTORY,
-  EXIT_LIST_HISTORY,
-  BRIDGE_STATUS_HISTORY,
-  BRIDGE_SERVER_HISTORY,
-  BRIDGE_EXTRAINFO_HISTORY,
-  BRIDGE_POOLASSIGN_HISTORY,
-}
-
-class DescriptorDownloader {
-
-  private final String protocolHostNameResourcePrefix =
-      "https://collector.torproject.org/recent/";;
-
-  private String directory;
-
-  private final File inDir = new File("in/recent");
-
-  public DescriptorDownloader(DescriptorType descriptorType) {
-    switch (descriptorType) {
-    case RELAY_CONSENSUSES:
-      this.directory = "relay-descriptors/consensuses/";
-      break;
-    case RELAY_SERVER_DESCRIPTORS:
-      this.directory = "relay-descriptors/server-descriptors/";
-      break;
-    case RELAY_EXTRA_INFOS:
-      this.directory = "relay-descriptors/extra-infos/";
-      break;
-    case EXIT_LISTS:
-      this.directory = "exit-lists/";
-      break;
-    case BRIDGE_STATUSES:
-      this.directory = "bridge-descriptors/statuses/";
-      break;
-    case BRIDGE_SERVER_DESCRIPTORS:
-      this.directory = "bridge-descriptors/server-descriptors/";
-      break;
-    case BRIDGE_EXTRA_INFOS:
-      this.directory = "bridge-descriptors/extra-infos/";
-      break;
-    case BRIDGE_POOL_ASSIGNMENTS:
-      this.directory = "bridge-pool-assignments/";
-      break;
-    default:
-      System.err.println("Unknown descriptor type.");
-      return;
-    }
-  }
-
-  private SortedSet<String> localFiles = new TreeSet<String>();
-
-  public int statLocalFiles() {
-    File localDirectory = new File(this.inDir, this.directory);
-    if (localDirectory.exists()) {
-      for (File file : localDirectory.listFiles()) {
-        this.localFiles.add(file.getName());
-      }
-    }
-    return this.localFiles.size();
-  }
-
-  private SortedSet<String> remoteFiles = new TreeSet<String>();
-
-  public int fetchRemoteDirectory() {
-    String directoryUrl = this.protocolHostNameResourcePrefix
-        + this.directory;
-    try {
-      URL u = new URL(directoryUrl);
-      HttpURLConnection huc = (HttpURLConnection) u.openConnection();
-      huc.setRequestMethod("GET");
-      huc.connect();
-      if (huc.getResponseCode() != 200) {
-        System.err.println("Could not fetch " + directoryUrl
-            + ": " + huc.getResponseCode() + " "
-            + huc.getResponseMessage() + ".  Skipping.");
-        return 0;
-      }
-      BufferedReader br = new BufferedReader(new InputStreamReader(
-          huc.getInputStream()));
-      String line;
-      while ((line = br.readLine()) != null) {
-        if (!line.trim().startsWith("<tr>") ||
-            !line.contains("<a href=\"")) {
-          continue;
-        }
-        String linePart = line.substring(
-            line.indexOf("<a href=\"") + "<a href=\"".length());
-        if (!linePart.contains("\"")) {
-          continue;
-        }
-        linePart = linePart.substring(0, linePart.indexOf("\""));
-        if (linePart.endsWith("/")) {
-          continue;
-        }
-        this.remoteFiles.add(linePart);
-      }
-      br.close();
-    } catch (IOException e) {
-      System.err.println("Could not fetch or parse " + directoryUrl
-          + ".  Skipping.");
-    }
-    return this.remoteFiles.size();
-  }
-
-  public int fetchRemoteFiles() {
-    int fetchedFiles = 0;
-    for (String remoteFile : this.remoteFiles) {
-      if (this.localFiles.contains(remoteFile)) {
-        continue;
-      }
-      String fileUrl = this.protocolHostNameResourcePrefix
-          + this.directory + remoteFile;
-      File localTempFile = new File(this.inDir, this.directory
-          + remoteFile + ".tmp");
-      File localFile = new File(this.inDir, this.directory + remoteFile);
-      try {
-        localFile.getParentFile().mkdirs();
-        URL u = new URL(fileUrl);
-        HttpURLConnection huc = (HttpURLConnection) u.openConnection();
-        huc.setRequestMethod("GET");
-        huc.addRequestProperty("Accept-Encoding", "gzip");
-        huc.connect();
-        if (huc.getResponseCode() != 200) {
-          System.err.println("Could not fetch " + fileUrl
-              + ": " + huc.getResponseCode() + " "
-              + huc.getResponseMessage() + ".  Skipping.");
-          continue;
-        }
-        long lastModified = huc.getHeaderFieldDate("Last-Modified", -1L);
-        InputStream is;
-        if (huc.getContentEncoding() != null &&
-            huc.getContentEncoding().equalsIgnoreCase("gzip")) {
-          is = new GZIPInputStream(huc.getInputStream());
-        } else {
-          is = huc.getInputStream();
-        }
-        BufferedInputStream bis = new BufferedInputStream(is);
-        BufferedOutputStream bos = new BufferedOutputStream(
-            new FileOutputStream(localTempFile));
-        int len;
-        byte[] data = new byte[1024];
-        while ((len = bis.read(data, 0, 1024)) >= 0) {
-          bos.write(data, 0, len);
-        }
-        bis.close();
-        bos.close();
-        localTempFile.renameTo(localFile);
-        if (lastModified >= 0) {
-          localFile.setLastModified(lastModified);
-        }
-        fetchedFiles++;
-      } catch (IOException e) {
-        System.err.println("Could not fetch or store " + fileUrl
-            + ".  Skipping.");
-      }
-    }
-    return fetchedFiles;
-  }
-
-  public int deleteOldLocalFiles() {
-    int deletedFiles = 0;
-    for (String localFile : this.localFiles) {
-      if (!this.remoteFiles.contains(localFile)) {
-        new File(this.inDir, this.directory + localFile).delete();
-        deletedFiles++;
-      }
-    }
-    return deletedFiles;
-  }
-}
-
-class DescriptorQueue {
-
-  private File inDir;
-
-  private File statusDir;
-
-  private DescriptorReader descriptorReader;
-
-  private File historyFile;
-
-  private Iterator<DescriptorFile> descriptorFiles;
-
-  private List<Descriptor> descriptors;
-
-  private int historySizeBefore;
-  public int getHistorySizeBefore() {
-    return this.historySizeBefore;
-  }
-
-  private int historySizeAfter;
-  public int getHistorySizeAfter() {
-    return this.historySizeAfter;
-  }
-
-  private long returnedDescriptors = 0L;
-  public long getReturnedDescriptors() {
-    return this.returnedDescriptors;
-  }
-
-  private long returnedBytes = 0L;
-  public long getReturnedBytes() {
-    return this.returnedBytes;
-  }
-
-  public DescriptorQueue(File inDir, File statusDir) {
-    this.inDir = inDir;
-    this.statusDir = statusDir;
-    this.descriptorReader =
-        DescriptorSourceFactory.createDescriptorReader();
-  }
-
-  public void addDirectory(DescriptorType descriptorType) {
-    String directoryName = null;
-    switch (descriptorType) {
-    case RELAY_CONSENSUSES:
-      directoryName = "relay-descriptors/consensuses";
-      break;
-    case RELAY_SERVER_DESCRIPTORS:
-      directoryName = "relay-descriptors/server-descriptors";
-      break;
-    case RELAY_EXTRA_INFOS:
-      directoryName = "relay-descriptors/extra-infos";
-      break;
-    case BRIDGE_STATUSES:
-      directoryName = "bridge-descriptors/statuses";
-      break;
-    case BRIDGE_SERVER_DESCRIPTORS:
-      directoryName = "bridge-descriptors/server-descriptors";
-      break;
-    case BRIDGE_EXTRA_INFOS:
-      directoryName = "bridge-descriptors/extra-infos";
-      break;
-    case BRIDGE_POOL_ASSIGNMENTS:
-      directoryName = "bridge-pool-assignments";
-      break;
-    case EXIT_LISTS:
-      directoryName = "exit-lists";
-      break;
-    default:
-      System.err.println("Unknown descriptor type.  Not adding directory "
-          + "to descriptor reader.");
-      return;
-    }
-    File directory = new File(this.inDir, directoryName);
-    if (directory.exists() && directory.isDirectory()) {
-      this.descriptorReader.addDirectory(directory);
-      this.descriptorReader.setMaxDescriptorFilesInQueue(1);
-    } else {
-      System.err.println("Directory " + directory.getAbsolutePath()
-          + " either does not exist or is not a directory.  Not adding "
-          + "to descriptor reader.");
-    }
-  }
-
-  public void readHistoryFile(DescriptorHistory descriptorHistory) {
-    String historyFileName = null;
-    switch (descriptorHistory) {
-    case RELAY_EXTRAINFO_HISTORY:
-      historyFileName = "relay-extrainfo-history";
-      break;
-    case BRIDGE_EXTRAINFO_HISTORY:
-      historyFileName = "bridge-extrainfo-history";
-      break;
-    case EXIT_LIST_HISTORY:
-      historyFileName = "exit-list-history";
-      break;
-    case BRIDGE_POOLASSIGN_HISTORY:
-      historyFileName = "bridge-poolassign-history";
-      break;
-    case RELAY_CONSENSUS_HISTORY:
-      historyFileName = "relay-consensus-history";
-      break;
-    case BRIDGE_STATUS_HISTORY:
-      historyFileName = "bridge-status-history";
-      break;
-    case RELAY_SERVER_HISTORY:
-      historyFileName = "relay-server-history";
-      break;
-    case BRIDGE_SERVER_HISTORY:
-      historyFileName = "bridge-server-history";
-      break;
-    default:
-      System.err.println("Unknown descriptor history.  Not excluding "
-          + "files.");
-      return;
-    }
-    this.historyFile = new File(this.statusDir, historyFileName);
-    if (this.historyFile.exists() && this.historyFile.isFile()) {
-      SortedMap<String, Long> excludedFiles = new TreeMap<String, Long>();
-      try {
-        BufferedReader br = new BufferedReader(new FileReader(
-            this.historyFile));
-        String line;
-        while ((line = br.readLine()) != null) {
-          try {
-            String[] parts = line.split(" ", 2);
-            excludedFiles.put(parts[1], Long.parseLong(parts[0]));
-          } catch (NumberFormatException e) {
-            System.err.println("Illegal line '" + line + "' in parse "
-                + "history.  Skipping line.");
-          }
-        }
-        br.close();
-      } catch (IOException e) {
-        System.err.println("Could not read history file '"
-            + this.historyFile.getAbsolutePath() + "'.  Not excluding "
-            + "descriptors in this execution.");
-        e.printStackTrace();
-        return;
-      }
-      this.historySizeBefore = excludedFiles.size();
-      this.descriptorReader.setExcludedFiles(excludedFiles);
-    }
-  }
-
-  public void writeHistoryFile() {
-    if (this.historyFile == null) {
-      return;
-    }
-    SortedMap<String, Long> excludedAndParsedFiles =
-        new TreeMap<String, Long>();
-    excludedAndParsedFiles.putAll(
-        this.descriptorReader.getExcludedFiles());
-    excludedAndParsedFiles.putAll(this.descriptorReader.getParsedFiles());
-    this.historySizeAfter = excludedAndParsedFiles.size();
-    try {
-      this.historyFile.getParentFile().mkdirs();
-      BufferedWriter bw = new BufferedWriter(new FileWriter(
-          this.historyFile));
-      for (Map.Entry<String, Long> e : excludedAndParsedFiles.entrySet()) {
-        String absolutePath = e.getKey();
-        long lastModifiedMillis = e.getValue();
-        bw.write(String.valueOf(lastModifiedMillis) + " " + absolutePath
-            + "\n");
-      }
-      bw.close();
-    } catch (IOException e) {
-      System.err.println("Could not write history file '"
-          + this.historyFile.getAbsolutePath() + "'.  Not excluding "
-          + "descriptors in next execution.");
-      return;
-    }
-  }
-
-  public Descriptor nextDescriptor() {
-    Descriptor nextDescriptor = null;
-    if (this.descriptorFiles == null) {
-      this.descriptorFiles = this.descriptorReader.readDescriptors();
-    }
-    while (this.descriptors == null && this.descriptorFiles.hasNext()) {
-      DescriptorFile descriptorFile = this.descriptorFiles.next();
-      if (descriptorFile.getException() != null) {
-        System.err.println("Could not parse "
-            + descriptorFile.getFileName());
-        descriptorFile.getException().printStackTrace();
-      }
-      if (descriptorFile.getDescriptors() != null &&
-          !descriptorFile.getDescriptors().isEmpty()) {
-        this.descriptors = descriptorFile.getDescriptors();
-      }
-    }
-    if (this.descriptors != null) {
-      nextDescriptor = this.descriptors.remove(0);
-      this.returnedDescriptors++;
-      this.returnedBytes += nextDescriptor.getRawDescriptorBytes().length;
-      if (this.descriptors.isEmpty()) {
-        this.descriptors = null;
-      }
-    }
-    return nextDescriptor;
-  }
-}
-
-public class DescriptorSource {
-
-  private final File inDir = new File("in/recent");
-
-  private final File statusDir = new File("status");
-
-  private List<DescriptorQueue> descriptorQueues;
-
-  public DescriptorSource() {
-    this.descriptorQueues = new ArrayList<DescriptorQueue>();
-    this.descriptorListeners =
-        new HashMap<DescriptorType, Set<DescriptorListener>>();
-    this.fingerprintListeners =
-        new HashMap<DescriptorType, Set<FingerprintListener>>();
-  }
-
-  private DescriptorQueue getDescriptorQueue(
-      DescriptorType descriptorType,
-      DescriptorHistory descriptorHistory) {
-    DescriptorQueue descriptorQueue = new DescriptorQueue(this.inDir,
-        this.statusDir);
-    descriptorQueue.addDirectory(descriptorType);
-    if (descriptorHistory != null) {
-      descriptorQueue.readHistoryFile(descriptorHistory);
-    }
-    this.descriptorQueues.add(descriptorQueue);
-    return descriptorQueue;
-  }
-
-  private Map<DescriptorType, Set<DescriptorListener>>
-      descriptorListeners;
-
-  private Map<DescriptorType, Set<FingerprintListener>>
-      fingerprintListeners;
-
-  public void registerDescriptorListener(DescriptorListener listener,
-      DescriptorType descriptorType) {
-    if (!this.descriptorListeners.containsKey(descriptorType)) {
-      this.descriptorListeners.put(descriptorType,
-          new HashSet<DescriptorListener>());
-    }
-    this.descriptorListeners.get(descriptorType).add(listener);
-  }
-
-  public void registerFingerprintListener(FingerprintListener listener,
-      DescriptorType descriptorType) {
-    if (!this.fingerprintListeners.containsKey(descriptorType)) {
-      this.fingerprintListeners.put(descriptorType,
-          new HashSet<FingerprintListener>());
-    }
-    this.fingerprintListeners.get(descriptorType).add(listener);
-  }
-
-  public void downloadDescriptors() {
-    for (DescriptorType descriptorType : DescriptorType.values()) {
-      this.downloadDescriptors(descriptorType);
-    }
-  }
-
-  private int localFilesBefore = 0, foundRemoteFiles = 0,
-      downloadedFiles = 0, deletedLocalFiles = 0;
-
-  private void downloadDescriptors(DescriptorType descriptorType) {
-    if (!this.descriptorListeners.containsKey(descriptorType) &&
-        !this.fingerprintListeners.containsKey(descriptorType)) {
-      return;
-    }
-    DescriptorDownloader descriptorDownloader =
-        new DescriptorDownloader(descriptorType);
-    this.localFilesBefore += descriptorDownloader.statLocalFiles();
-    this.foundRemoteFiles +=
-        descriptorDownloader.fetchRemoteDirectory();
-    this.downloadedFiles += descriptorDownloader.fetchRemoteFiles();
-    this.deletedLocalFiles += descriptorDownloader.deleteOldLocalFiles();
-  }
-
-  public void readDescriptors() {
-    /* Careful when changing the order of parsing descriptor types!  The
-     * various status updaters may base assumptions on this order. */
-    this.readDescriptors(DescriptorType.RELAY_SERVER_DESCRIPTORS,
-        DescriptorHistory.RELAY_SERVER_HISTORY, true);
-    this.readDescriptors(DescriptorType.RELAY_EXTRA_INFOS,
-        DescriptorHistory.RELAY_EXTRAINFO_HISTORY, true);
-    this.readDescriptors(DescriptorType.EXIT_LISTS,
-        DescriptorHistory.EXIT_LIST_HISTORY, true);
-    this.readDescriptors(DescriptorType.RELAY_CONSENSUSES,
-        DescriptorHistory.RELAY_CONSENSUS_HISTORY, true);
-    this.readDescriptors(DescriptorType.BRIDGE_SERVER_DESCRIPTORS,
-        DescriptorHistory.BRIDGE_SERVER_HISTORY, false);
-    this.readDescriptors(DescriptorType.BRIDGE_EXTRA_INFOS,
-        DescriptorHistory.BRIDGE_EXTRAINFO_HISTORY, false);
-    this.readDescriptors(DescriptorType.BRIDGE_POOL_ASSIGNMENTS,
-        DescriptorHistory.BRIDGE_POOLASSIGN_HISTORY, false);
-    this.readDescriptors(DescriptorType.BRIDGE_STATUSES,
-        DescriptorHistory.BRIDGE_STATUS_HISTORY, false);
-  }
-
-  private void readDescriptors(DescriptorType descriptorType,
-      DescriptorHistory descriptorHistory, boolean relay) {
-    if (!this.descriptorListeners.containsKey(descriptorType) &&
-        !this.fingerprintListeners.containsKey(descriptorType)) {
-      return;
-    }
-    Set<DescriptorListener> descriptorListeners =
-        this.descriptorListeners.get(descriptorType);
-    Set<FingerprintListener> fingerprintListeners =
-        this.fingerprintListeners.get(descriptorType);
-    DescriptorQueue descriptorQueue = this.getDescriptorQueue(
-        descriptorType, descriptorHistory);
-    Descriptor descriptor;
-    while ((descriptor = descriptorQueue.nextDescriptor()) != null) {
-      for (DescriptorListener descriptorListener : descriptorListeners) {
-        descriptorListener.processDescriptor(descriptor, relay);
-      }
-      if (fingerprintListeners == null) {
-        continue;
-      }
-      SortedSet<String> fingerprints = new TreeSet<String>();
-      if (descriptorType == DescriptorType.RELAY_CONSENSUSES &&
-          descriptor instanceof RelayNetworkStatusConsensus) {
-        fingerprints.addAll(((RelayNetworkStatusConsensus) descriptor).
-            getStatusEntries().keySet());
-      } else if (descriptorType
-          == DescriptorType.RELAY_SERVER_DESCRIPTORS &&
-          descriptor instanceof ServerDescriptor) {
-        fingerprints.add(((ServerDescriptor) descriptor).
-            getFingerprint());
-      } else if (descriptorType == DescriptorType.RELAY_EXTRA_INFOS &&
-          descriptor instanceof ExtraInfoDescriptor) {
-        fingerprints.add(((ExtraInfoDescriptor) descriptor).
-            getFingerprint());
-      } else if (descriptorType == DescriptorType.EXIT_LISTS &&
-          descriptor instanceof ExitList) {
-        for (ExitListEntry entry :
-            ((ExitList) descriptor).getExitListEntries()) {
-          fingerprints.add(entry.getFingerprint());
-        }
-      } else if (descriptorType == DescriptorType.BRIDGE_STATUSES &&
-          descriptor instanceof BridgeNetworkStatus) {
-        fingerprints.addAll(((BridgeNetworkStatus) descriptor).
-            getStatusEntries().keySet());
-      } else if (descriptorType ==
-          DescriptorType.BRIDGE_SERVER_DESCRIPTORS &&
-          descriptor instanceof ServerDescriptor) {
-        fingerprints.add(((ServerDescriptor) descriptor).
-            getFingerprint());
-      } else if (descriptorType == DescriptorType.BRIDGE_EXTRA_INFOS &&
-          descriptor instanceof ExtraInfoDescriptor) {
-        fingerprints.add(((ExtraInfoDescriptor) descriptor).
-            getFingerprint());
-      } else if (descriptorType ==
-          DescriptorType.BRIDGE_POOL_ASSIGNMENTS &&
-          descriptor instanceof BridgePoolAssignment) {
-        fingerprints.addAll(((BridgePoolAssignment) descriptor).
-            getEntries().keySet());
-      }
-      for (FingerprintListener fingerprintListener :
-          fingerprintListeners) {
-        fingerprintListener.processFingerprints(fingerprints, relay);
-      }
-    }
-    switch (descriptorType) {
-    case RELAY_CONSENSUSES:
-      Logger.printStatusTime("Read relay network consensuses");
-      break;
-    case RELAY_SERVER_DESCRIPTORS:
-      Logger.printStatusTime("Read relay server descriptors");
-      break;
-    case RELAY_EXTRA_INFOS:
-      Logger.printStatusTime("Read relay extra-info descriptors");
-      break;
-    case EXIT_LISTS:
-      Logger.printStatusTime("Read exit lists");
-      break;
-    case BRIDGE_STATUSES:
-      Logger.printStatusTime("Read bridge network statuses");
-      break;
-    case BRIDGE_SERVER_DESCRIPTORS:
-      Logger.printStatusTime("Read bridge server descriptors");
-      break;
-    case BRIDGE_EXTRA_INFOS:
-      Logger.printStatusTime("Read bridge extra-info descriptors");
-      break;
-    case BRIDGE_POOL_ASSIGNMENTS:
-      Logger.printStatusTime("Read bridge-pool assignments");
-      break;
-    }
-  }
-
-  public void writeHistoryFiles() {
-    for (DescriptorQueue descriptorQueue : this.descriptorQueues) {
-      descriptorQueue.writeHistoryFile();
-    }
-  }
-
-  public String getStatsString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append("    " + this.localFilesBefore + " descriptor files found "
-        + "locally\n");
-    sb.append("    " + this.foundRemoteFiles + " descriptor files found "
-        + "remotely\n");
-    sb.append("    " + this.downloadedFiles + " descriptor files "
-        + "downloaded from remote\n");
-    sb.append("    " + this.deletedLocalFiles + " descriptor files "
-        + "deleted locally\n");
-    sb.append("    " + this.descriptorQueues.size() + " descriptor "
-        + "queues created\n");
-    int historySizeBefore = 0, historySizeAfter = 0;
-    long descriptors = 0L, bytes = 0L;
-    for (DescriptorQueue descriptorQueue : descriptorQueues) {
-      historySizeBefore += descriptorQueue.getHistorySizeBefore();
-      historySizeAfter += descriptorQueue.getHistorySizeAfter();
-      descriptors += descriptorQueue.getReturnedDescriptors();
-      bytes += descriptorQueue.getReturnedBytes();
-    }
-    sb.append("    " + Logger.formatDecimalNumber(historySizeBefore)
-        + " descriptors excluded from this execution\n");
-    sb.append("    " + Logger.formatDecimalNumber(descriptors)
-        + " descriptors provided\n");
-    sb.append("    " + Logger.formatBytes(bytes) + " provided\n");
-    sb.append("    " + Logger.formatDecimalNumber(historySizeAfter)
-        + " descriptors excluded from next execution\n");
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/DetailsDocument.java b/src/org/torproject/onionoo/DetailsDocument.java
deleted file mode 100644
index 2df6e78..0000000
--- a/src/org/torproject/onionoo/DetailsDocument.java
+++ /dev/null
@@ -1,365 +0,0 @@
-/* Copyright 2013--2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.List;
-import java.util.Map;
-
-import org.apache.commons.lang.StringEscapeUtils;
-
-public class DetailsDocument extends Document {
-
-  /* We must ensure that details files only contain ASCII characters
-   * and no UTF-8 characters.  While UTF-8 characters are perfectly
-   * valid in JSON, this would break compatibility with existing files
-   * pretty badly.  We do this by escaping non-ASCII characters, e.g.,
-   * \u00F2.  Gson won't treat this as UTF-8, but will think that we want
-   * to write six characters '\', 'u', '0', '0', 'F', '2'.  The only thing
-   * we'll have to do is to change back the '\\' that Gson writes for the
-   * '\'. */
-  private static String escapeJSON(String s) {
-    return s == null ? null :
-        StringEscapeUtils.escapeJavaScript(s).replaceAll("\\\\'", "'");
-  }
-  private static String unescapeJSON(String s) {
-    return s == null ? null :
-        StringEscapeUtils.unescapeJavaScript(s.replaceAll("'", "\\'"));
-  }
-
-  private String nickname;
-  public void setNickname(String nickname) {
-    this.nickname = nickname;
-  }
-  public String getNickname() {
-    return this.nickname;
-  }
-
-  private String fingerprint;
-  public void setFingerprint(String fingerprint) {
-    this.fingerprint = fingerprint;
-  }
-  public String getFingerprint() {
-    return this.fingerprint;
-  }
-
-  private String hashed_fingerprint;
-  public void setHashedFingerprint(String hashedFingerprint) {
-    this.hashed_fingerprint = hashedFingerprint;
-  }
-  public String getHashedFingerprint() {
-    return this.hashed_fingerprint;
-  }
-
-  private List<String> or_addresses;
-  public void setOrAddresses(List<String> orAddresses) {
-    this.or_addresses = orAddresses;
-  }
-  public List<String> getOrAddresses() {
-    return this.or_addresses;
-  }
-
-  private List<String> exit_addresses;
-  public void setExitAddresses(List<String> exitAddresses) {
-    this.exit_addresses = exitAddresses;
-  }
-  public List<String> getExitAddresses() {
-    return this.exit_addresses;
-  }
-
-  private String dir_address;
-  public void setDirAddress(String dirAddress) {
-    this.dir_address = dirAddress;
-  }
-  public String getDirAddress() {
-    return this.dir_address;
-  }
-
-  private String last_seen;
-  public void setLastSeen(String lastSeen) {
-    this.last_seen = lastSeen;
-  }
-  public String getLastSeen() {
-    return this.last_seen;
-  }
-
-  private String last_changed_address_or_port;
-  public void setLastChangedAddressOrPort(
-      String lastChangedAddressOrPort) {
-    this.last_changed_address_or_port = lastChangedAddressOrPort;
-  }
-  public String getLastChangedAddressOrPort() {
-    return this.last_changed_address_or_port;
-  }
-
-  private String first_seen;
-  public void setFirstSeen(String firstSeen) {
-    this.first_seen = firstSeen;
-  }
-  public String getFirstSeen() {
-    return this.first_seen;
-  }
-
-  private Boolean running;
-  public void setRunning(Boolean running) {
-    this.running = running;
-  }
-  public Boolean getRunning() {
-    return this.running;
-  }
-
-  private List<String> flags;
-  public void setFlags(List<String> flags) {
-    this.flags = flags;
-  }
-  public List<String> getFlags() {
-    return this.flags;
-  }
-
-  private String country;
-  public void setCountry(String country) {
-    this.country = country;
-  }
-  public String getCountry() {
-    return this.country;
-  }
-
-  private String country_name;
-  public void setCountryName(String countryName) {
-    this.country_name = escapeJSON(countryName);
-  }
-  public String getCountryName() {
-    return unescapeJSON(this.country_name);
-  }
-
-  private String region_name;
-  public void setRegionName(String regionName) {
-    this.region_name = escapeJSON(regionName);
-  }
-  public String getRegionName() {
-    return unescapeJSON(this.region_name);
-  }
-
-  private String city_name;
-  public void setCityName(String cityName) {
-    this.city_name = escapeJSON(cityName);
-  }
-  public String getCityName() {
-    return unescapeJSON(this.city_name);
-  }
-
-  private Float latitude;
-  public void setLatitude(Float latitude) {
-    this.latitude = latitude;
-  }
-  public Float getLatitude() {
-    return this.latitude;
-  }
-
-  private Float longitude;
-  public void setLongitude(Float longitude) {
-    this.longitude = longitude;
-  }
-  public Float getLongitude() {
-    return this.longitude;
-  }
-
-  private String as_number;
-  public void setAsNumber(String asNumber) {
-    this.as_number = escapeJSON(asNumber);
-  }
-  public String getAsNumber() {
-    return unescapeJSON(this.as_number);
-  }
-
-  private String as_name;
-  public void setAsName(String asName) {
-    this.as_name = escapeJSON(asName);
-  }
-  public String getAsName() {
-    return unescapeJSON(this.as_name);
-  }
-
-  private Long consensus_weight;
-  public void setConsensusWeight(Long consensusWeight) {
-    this.consensus_weight = consensusWeight;
-  }
-  public Long getConsensusWeight() {
-    return this.consensus_weight;
-  }
-
-  private String host_name;
-  public void setHostName(String hostName) {
-    this.host_name = escapeJSON(hostName);
-  }
-  public String getHostName() {
-    return unescapeJSON(this.host_name);
-  }
-
-  private String last_restarted;
-  public void setLastRestarted(String lastRestarted) {
-    this.last_restarted = lastRestarted;
-  }
-  public String getLastRestarted() {
-    return this.last_restarted;
-  }
-
-  private Integer bandwidth_rate;
-  public void setBandwidthRate(Integer bandwidthRate) {
-    this.bandwidth_rate = bandwidthRate;
-  }
-  public Integer getBandwidthRate() {
-    return this.bandwidth_rate;
-  }
-
-  private Integer bandwidth_burst;
-  public void setBandwidthBurst(Integer bandwidthBurst) {
-    this.bandwidth_burst = bandwidthBurst;
-  }
-  public Integer getBandwidthBurst() {
-    return this.bandwidth_burst;
-  }
-
-  private Integer observed_bandwidth;
-  public void setObservedBandwidth(Integer observedBandwidth) {
-    this.observed_bandwidth = observedBandwidth;
-  }
-  public Integer getObservedBandwidth() {
-    return this.observed_bandwidth;
-  }
-
-  private Integer advertised_bandwidth;
-  public void setAdvertisedBandwidth(Integer advertisedBandwidth) {
-    this.advertised_bandwidth = advertisedBandwidth;
-  }
-  public Integer getAdvertisedBandwidth() {
-    return this.advertised_bandwidth;
-  }
-
-  private List<String> exit_policy;
-  public void setExitPolicy(List<String> exitPolicy) {
-    this.exit_policy = exitPolicy;
-  }
-  public List<String> getExitPolicy() {
-    return this.exit_policy;
-  }
-
-  private Map<String, List<String>> exit_policy_summary;
-  public void setExitPolicySummary(
-      Map<String, List<String>> exitPolicySummary) {
-    this.exit_policy_summary = exitPolicySummary;
-  }
-  public Map<String, List<String>> getExitPolicySummary() {
-    return this.exit_policy_summary;
-  }
-
-  private Map<String, List<String>> exit_policy_v6_summary;
-  public void setExitPolicyV6Summary(
-      Map<String, List<String>> exitPolicyV6Summary) {
-    this.exit_policy_v6_summary = exitPolicyV6Summary;
-  }
-  public Map<String, List<String>> getExitPolicyV6Summary() {
-    return this.exit_policy_v6_summary;
-  }
-
-  private String contact;
-  public void setContact(String contact) {
-    this.contact = escapeJSON(contact);
-  }
-  public String getContact() {
-    return unescapeJSON(this.contact);
-  }
-
-  private String platform;
-  public void setPlatform(String platform) {
-    this.platform = escapeJSON(platform);
-  }
-  public String getPlatform() {
-    return unescapeJSON(this.platform);
-  }
-
-  private List<String> family;
-  public void setFamily(List<String> family) {
-    this.family = family;
-  }
-  public List<String> getFamily() {
-    return this.family;
-  }
-
-  private Float advertised_bandwidth_fraction;
-  public void setAdvertisedBandwidthFraction(
-      Float advertisedBandwidthFraction) {
-    if (advertisedBandwidthFraction == null ||
-        advertisedBandwidthFraction >= 0.0) {
-      this.advertised_bandwidth_fraction = advertisedBandwidthFraction;
-    }
-  }
-  public Float getAdvertisedBandwidthFraction() {
-    return this.advertised_bandwidth_fraction;
-  }
-
-  private Float consensus_weight_fraction;
-  public void setConsensusWeightFraction(Float consensusWeightFraction) {
-    if (consensusWeightFraction == null ||
-        consensusWeightFraction >= 0.0) {
-      this.consensus_weight_fraction = consensusWeightFraction;
-    }
-  }
-  public Float getConsensusWeightFraction() {
-    return this.consensus_weight_fraction;
-  }
-
-  private Float guard_probability;
-  public void setGuardProbability(Float guardProbability) {
-    if (guardProbability == null || guardProbability >= 0.0) {
-      this.guard_probability = guardProbability;
-    }
-  }
-  public Float getGuardProbability() {
-    return this.guard_probability;
-  }
-
-  private Float middle_probability;
-  public void setMiddleProbability(Float middleProbability) {
-    if (middleProbability == null || middleProbability >= 0.0) {
-      this.middle_probability = middleProbability;
-    }
-  }
-  public Float getMiddleProbability() {
-    return this.middle_probability;
-  }
-
-  private Float exit_probability;
-  public void setExitProbability(Float exitProbability) {
-    if (exitProbability == null || exitProbability >= 0.0) {
-      this.exit_probability = exitProbability;
-    }
-  }
-  public Float getExitProbability() {
-    return this.exit_probability;
-  }
-
-  private Boolean recommended_version;
-  public void setRecommendedVersion(Boolean recommendedVersion) {
-    this.recommended_version = recommendedVersion;
-  }
-  public Boolean getRecommendedVersion() {
-    return this.recommended_version;
-  }
-
-  private Boolean hibernating;
-  public void setHibernating(Boolean hibernating) {
-    this.hibernating = hibernating;
-  }
-  public Boolean getHibernating() {
-    return this.hibernating;
-  }
-
-  private String pool_assignment;
-  public void setPoolAssignment(String poolAssignment) {
-    this.pool_assignment = poolAssignment;
-  }
-  public String getPoolAssignment() {
-    return this.pool_assignment;
-  }
-}
-
diff --git a/src/org/torproject/onionoo/DetailsDocumentWriter.java b/src/org/torproject/onionoo/DetailsDocumentWriter.java
deleted file mode 100644
index a70efd0..0000000
--- a/src/org/torproject/onionoo/DetailsDocumentWriter.java
+++ /dev/null
@@ -1,222 +0,0 @@
-package org.torproject.onionoo;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-public class DetailsDocumentWriter implements FingerprintListener,
-    DocumentWriter {
-
-  private DescriptorSource descriptorSource;
-
-  private DocumentStore documentStore;
-
-  private long now;
-
-  public DetailsDocumentWriter() {
-    this.descriptorSource = ApplicationFactory.getDescriptorSource();
-    this.documentStore = ApplicationFactory.getDocumentStore();
-    this.now = ApplicationFactory.getTime().currentTimeMillis();
-    this.registerFingerprintListeners();
-  }
-
-  private void registerFingerprintListeners() {
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.RELAY_CONSENSUSES);
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.RELAY_SERVER_DESCRIPTORS);
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.BRIDGE_STATUSES);
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.BRIDGE_SERVER_DESCRIPTORS);
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.BRIDGE_POOL_ASSIGNMENTS);
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.EXIT_LISTS);
-  }
-
-  private SortedSet<String> newRelays = new TreeSet<String>(),
-      newBridges = new TreeSet<String>();
-
-  public void processFingerprints(SortedSet<String> fingerprints,
-      boolean relay) {
-    if (relay) {
-      this.newRelays.addAll(fingerprints);
-    } else {
-      this.newBridges.addAll(fingerprints);
-    }
-  }
-
-  public void writeDocuments() {
-    this.updateRelayDetailsFiles();
-    this.updateBridgeDetailsFiles();
-    Logger.printStatusTime("Wrote details document files");
-  }
-
-  private void updateRelayDetailsFiles() {
-    for (String fingerprint : this.newRelays) {
-
-      /* Generate network-status-specific part. */
-      NodeStatus entry = this.documentStore.retrieve(NodeStatus.class,
-          true, fingerprint);
-      if (entry == null) {
-        continue;
-      }
-      DetailsDocument detailsDocument = new DetailsDocument();
-      detailsDocument.setNickname(entry.getNickname());
-      detailsDocument.setFingerprint(fingerprint);
-      List<String> orAddresses = new ArrayList<String>();
-      orAddresses.add(entry.getAddress() + ":" + entry.getOrPort());
-      for (String orAddress : entry.getOrAddressesAndPorts()) {
-        orAddresses.add(orAddress.toLowerCase());
-      }
-      detailsDocument.setOrAddresses(orAddresses);
-      if (entry.getDirPort() != 0) {
-        detailsDocument.setDirAddress(entry.getAddress() + ":"
-            + entry.getDirPort());
-      }
-      detailsDocument.setLastSeen(DateTimeHelper.format(
-          entry.getLastSeenMillis()));
-      detailsDocument.setFirstSeen(DateTimeHelper.format(
-          entry.getFirstSeenMillis()));
-      detailsDocument.setLastChangedAddressOrPort(
-          DateTimeHelper.format(entry.getLastChangedOrAddress()));
-      detailsDocument.setRunning(entry.getRunning());
-      if (!entry.getRelayFlags().isEmpty()) {
-        detailsDocument.setFlags(new ArrayList<String>(
-            entry.getRelayFlags()));
-      }
-      detailsDocument.setCountry(entry.getCountryCode());
-      detailsDocument.setLatitude(entry.getLatitude());
-      detailsDocument.setLongitude(entry.getLongitude());
-      detailsDocument.setCountryName(entry.getCountryName());
-      detailsDocument.setRegionName(entry.getRegionName());
-      detailsDocument.setCityName(entry.getCityName());
-      detailsDocument.setAsNumber(entry.getASNumber());
-      detailsDocument.setAsName(entry.getASName());
-      detailsDocument.setConsensusWeight(entry.getConsensusWeight());
-      detailsDocument.setHostName(entry.getHostName());
-      detailsDocument.setAdvertisedBandwidthFraction(
-          (float) entry.getAdvertisedBandwidthFraction());
-      detailsDocument.setConsensusWeightFraction(
-          (float) entry.getConsensusWeightFraction());
-      detailsDocument.setGuardProbability(
-          (float) entry.getGuardProbability());
-      detailsDocument.setMiddleProbability(
-          (float) entry.getMiddleProbability());
-      detailsDocument.setExitProbability(
-          (float) entry.getExitProbability());
-      String defaultPolicy = entry.getDefaultPolicy();
-      String portList = entry.getPortList();
-      if (defaultPolicy != null && (defaultPolicy.equals("accept") ||
-          defaultPolicy.equals("reject")) && portList != null) {
-        Map<String, List<String>> exitPolicySummary =
-            new HashMap<String, List<String>>();
-        List<String> portsOrPortRanges = Arrays.asList(
-            portList.split(","));
-        exitPolicySummary.put(defaultPolicy, portsOrPortRanges);
-        detailsDocument.setExitPolicySummary(exitPolicySummary);
-      }
-      detailsDocument.setRecommendedVersion(
-          entry.getRecommendedVersion());
-
-      /* Append descriptor-specific part and exit addresses from details
-       * status file. */
-      DetailsStatus detailsStatus = this.documentStore.retrieve(
-          DetailsStatus.class, true, fingerprint);
-      if (detailsStatus != null) {
-        detailsDocument.setLastRestarted(
-            detailsStatus.getLastRestarted());
-        detailsDocument.setBandwidthRate(
-            detailsStatus.getBandwidthRate());
-        detailsDocument.setBandwidthBurst(
-            detailsStatus.getBandwidthBurst());
-        detailsDocument.setObservedBandwidth(
-            detailsStatus.getObservedBandwidth());
-        detailsDocument.setAdvertisedBandwidth(
-            detailsStatus.getAdvertisedBandwidth());
-        detailsDocument.setExitPolicy(detailsStatus.getExitPolicy());
-        detailsDocument.setContact(detailsStatus.getContact());
-        detailsDocument.setPlatform(detailsStatus.getPlatform());
-        detailsDocument.setFamily(detailsStatus.getFamily());
-        detailsDocument.setExitPolicyV6Summary(
-            detailsStatus.getExitPolicyV6Summary());
-        detailsDocument.setHibernating(detailsStatus.getHibernating());
-        if (detailsStatus.getExitAddresses() != null) {
-          SortedSet<String> exitAddresses = new TreeSet<String>();
-          for (Map.Entry<String, Long> e :
-              detailsStatus.getExitAddresses().entrySet()) {
-            String exitAddress = e.getKey().toLowerCase();
-            long scanMillis = e.getValue();
-            if (!entry.getAddress().equals(exitAddress) &&
-                !entry.getOrAddresses().contains(exitAddress) &&
-                scanMillis >= this.now - DateTimeHelper.ONE_DAY) {
-              exitAddresses.add(exitAddress);
-            }
-          }
-          if (!exitAddresses.isEmpty()) {
-            detailsDocument.setExitAddresses(new ArrayList<String>(
-                exitAddresses));
-          }
-        }
-      }
-
-      /* Write details file to disk. */
-      this.documentStore.store(detailsDocument, fingerprint);
-    }
-  }
-
-  private void updateBridgeDetailsFiles() {
-    for (String fingerprint : this.newBridges) {
-
-      /* Generate network-status-specific part. */
-      NodeStatus entry = this.documentStore.retrieve(NodeStatus.class,
-          true, fingerprint);
-      if (entry == null) {
-        continue;
-      }
-      DetailsDocument detailsDocument = new DetailsDocument();
-      detailsDocument.setNickname(entry.getNickname());
-      detailsDocument.setHashedFingerprint(fingerprint);
-      String address = entry.getAddress();
-      List<String> orAddresses = new ArrayList<String>();
-      orAddresses.add(address + ":" + entry.getOrPort());
-      for (String orAddress : entry.getOrAddressesAndPorts()) {
-        orAddresses.add(orAddress.toLowerCase());
-      }
-      detailsDocument.setOrAddresses(orAddresses);
-      detailsDocument.setLastSeen(DateTimeHelper.format(
-          entry.getLastSeenMillis()));
-      detailsDocument.setFirstSeen(DateTimeHelper.format(
-          entry.getFirstSeenMillis()));
-      detailsDocument.setRunning(entry.getRunning());
-      detailsDocument.setFlags(new ArrayList<String>(
-          entry.getRelayFlags()));
-
-      /* Append descriptor-specific part from details status file. */
-      DetailsStatus detailsStatus = this.documentStore.retrieve(
-          DetailsStatus.class, true, fingerprint);
-      if (detailsStatus != null) {
-        detailsDocument.setLastRestarted(
-            detailsStatus.getLastRestarted());
-        detailsDocument.setAdvertisedBandwidth(
-            detailsStatus.getAdvertisedBandwidth());
-        detailsDocument.setPlatform(detailsStatus.getPlatform());
-        detailsDocument.setPoolAssignment(
-            detailsStatus.getPoolAssignment());
-      }
-
-      /* Write details file to disk. */
-      this.documentStore.store(detailsDocument, fingerprint);
-    }
-  }
-
-  public String getStatsString() {
-    /* TODO Add statistics string. */
-    return null;
-  }
-}
diff --git a/src/org/torproject/onionoo/DetailsStatus.java b/src/org/torproject/onionoo/DetailsStatus.java
deleted file mode 100644
index 1951c89..0000000
--- a/src/org/torproject/onionoo/DetailsStatus.java
+++ /dev/null
@@ -1,141 +0,0 @@
-/* Copyright 2013--2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.List;
-import java.util.Map;
-
-import org.apache.commons.lang.StringEscapeUtils;
-
-public class DetailsStatus extends Document {
-
-  /* We must ensure that details files only contain ASCII characters
-   * and no UTF-8 characters.  While UTF-8 characters are perfectly
-   * valid in JSON, this would break compatibility with existing files
-   * pretty badly.  We do this by escaping non-ASCII characters, e.g.,
-   * \u00F2.  Gson won't treat this as UTF-8, but will think that we want
-   * to write six characters '\', 'u', '0', '0', 'F', '2'.  The only thing
-   * we'll have to do is to change back the '\\' that Gson writes for the
-   * '\'. */
-  private static String escapeJSON(String s) {
-    return s == null ? null :
-        StringEscapeUtils.escapeJavaScript(s).replaceAll("\\\\'", "'");
-  }
-  private static String unescapeJSON(String s) {
-    return s == null ? null :
-        StringEscapeUtils.unescapeJavaScript(s.replaceAll("'", "\\'"));
-  }
-
-  private String desc_published;
-  public void setDescPublished(String descPublished) {
-    this.desc_published = descPublished;
-  }
-  public String getDescPublished() {
-    return this.desc_published;
-  }
-
-  private String last_restarted;
-  public void setLastRestarted(String lastRestarted) {
-    this.last_restarted = lastRestarted;
-  }
-  public String getLastRestarted() {
-    return this.last_restarted;
-  }
-
-  private Integer bandwidth_rate;
-  public void setBandwidthRate(Integer bandwidthRate) {
-    this.bandwidth_rate = bandwidthRate;
-  }
-  public Integer getBandwidthRate() {
-    return this.bandwidth_rate;
-  }
-
-  private Integer bandwidth_burst;
-  public void setBandwidthBurst(Integer bandwidthBurst) {
-    this.bandwidth_burst = bandwidthBurst;
-  }
-  public Integer getBandwidthBurst() {
-    return this.bandwidth_burst;
-  }
-
-  private Integer observed_bandwidth;
-  public void setObservedBandwidth(Integer observedBandwidth) {
-    this.observed_bandwidth = observedBandwidth;
-  }
-  public Integer getObservedBandwidth() {
-    return this.observed_bandwidth;
-  }
-
-  private Integer advertised_bandwidth;
-  public void setAdvertisedBandwidth(Integer advertisedBandwidth) {
-    this.advertised_bandwidth = advertisedBandwidth;
-  }
-  public Integer getAdvertisedBandwidth() {
-    return this.advertised_bandwidth;
-  }
-
-  private List<String> exit_policy;
-  public void setExitPolicy(List<String> exitPolicy) {
-    this.exit_policy = exitPolicy;
-  }
-  public List<String> getExitPolicy() {
-    return this.exit_policy;
-  }
-
-  private String contact;
-  public void setContact(String contact) {
-    this.contact = escapeJSON(contact);
-  }
-  public String getContact() {
-    return unescapeJSON(this.contact);
-  }
-
-  private String platform;
-  public void setPlatform(String platform) {
-    this.platform = escapeJSON(platform);
-  }
-  public String getPlatform() {
-    return unescapeJSON(this.platform);
-  }
-
-  private List<String> family;
-  public void setFamily(List<String> family) {
-    this.family = family;
-  }
-  public List<String> getFamily() {
-    return this.family;
-  }
-
-  private Map<String, List<String>> exit_policy_v6_summary;
-  public void setExitPolicyV6Summary(
-      Map<String, List<String>> exitPolicyV6Summary) {
-    this.exit_policy_v6_summary = exitPolicyV6Summary;
-  }
-  public Map<String, List<String>> getExitPolicyV6Summary() {
-    return this.exit_policy_v6_summary;
-  }
-
-  private Boolean hibernating;
-  public void setHibernating(Boolean hibernating) {
-    this.hibernating = hibernating;
-  }
-  public Boolean getHibernating() {
-    return this.hibernating;
-  }
-
-  private String pool_assignment;
-  public void setPoolAssignment(String poolAssignment) {
-    this.pool_assignment = poolAssignment;
-  }
-  public String getPoolAssignment() {
-    return this.pool_assignment;
-  }
-
-  private Map<String, Long> exit_addresses;
-  public void setExitAddresses(Map<String, Long> exitAddresses) {
-    this.exit_addresses = exitAddresses;
-  }
-  public Map<String, Long> getExitAddresses() {
-    return this.exit_addresses;
-  }
-}
diff --git a/src/org/torproject/onionoo/Document.java b/src/org/torproject/onionoo/Document.java
deleted file mode 100644
index f49574c..0000000
--- a/src/org/torproject/onionoo/Document.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-public abstract class Document {
-
-  private transient String documentString;
-  public void setDocumentString(String documentString) {
-    this.documentString = documentString;
-  }
-  public String getDocumentString() {
-    return this.documentString;
-  }
-
-  public void fromDocumentString(String documentString) {
-    /* Subclasses may override this method to parse documentString. */
-  }
-
-  public String toDocumentString() {
-    /* Subclasses may override this method to write documentString. */
-    return null;
-  }
-}
-
diff --git a/src/org/torproject/onionoo/DocumentStore.java b/src/org/torproject/onionoo/DocumentStore.java
deleted file mode 100644
index 434ecb6..0000000
--- a/src/org/torproject/onionoo/DocumentStore.java
+++ /dev/null
@@ -1,744 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.io.BufferedInputStream;
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
-import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileReader;
-import java.io.FileWriter;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.Stack;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.JsonParseException;
-
-// TODO For later migration from disk to database, do the following:
-// - read from database and then from disk if not found
-// - write only to database, delete from disk once in database
-// - move entirely to database once disk is "empty"
-// TODO Also look into simple key-value stores instead of real databases.
-public class DocumentStore {
-
-  private final File statusDir = new File("status");
-
-  private File outDir = new File("out");
-  public void setOutDir(File outDir) {
-    this.outDir = outDir;
-  }
-
-  private Time time;
-
-  public DocumentStore() {
-    this.time = ApplicationFactory.getTime();
-  }
-
-  private long listOperations = 0L, listedFiles = 0L, storedFiles = 0L,
-      storedBytes = 0L, retrievedFiles = 0L, retrievedBytes = 0L,
-      removedFiles = 0L;
-
-  /* Node statuses and summary documents are cached in memory, as opposed
-   * to all other document types.  These caches are initialized when first
-   * accessing or modifying a NodeStatus or SummaryDocument document,
-   * respectively. */
-  private SortedMap<String, NodeStatus> cachedNodeStatuses;
-  private SortedMap<String, SummaryDocument> cachedSummaryDocuments;
-
-  public <T extends Document> SortedSet<String> list(
-      Class<T> documentType) {
-    if (documentType.equals(NodeStatus.class)) {
-      return this.listNodeStatuses();
-    } else if (documentType.equals(SummaryDocument.class)) {
-      return this.listSummaryDocuments();
-    } else {
-      return this.listDocumentFiles(documentType);
-    }
-  }
-
-  private SortedSet<String> listNodeStatuses() {
-    if (this.cachedNodeStatuses == null) {
-      this.cacheNodeStatuses();
-    }
-    return new TreeSet<String>(this.cachedNodeStatuses.keySet());
-  }
-
-  private void cacheNodeStatuses() {
-    SortedMap<String, NodeStatus> parsedNodeStatuses =
-        new TreeMap<String, NodeStatus>();
-    File directory = this.statusDir;
-    if (directory != null) {
-      File summaryFile = new File(directory, "summary");
-      if (summaryFile.exists()) {
-        try {
-          BufferedReader br = new BufferedReader(new FileReader(
-              summaryFile));
-          String line;
-          while ((line = br.readLine()) != null) {
-            if (line.length() == 0) {
-              continue;
-            }
-            NodeStatus node = NodeStatus.fromString(line);
-            if (node != null) {
-              parsedNodeStatuses.put(node.getFingerprint(), node);
-            }
-          }
-          br.close();
-          this.listedFiles += parsedNodeStatuses.size();
-          this.listOperations++;
-        } catch (IOException e) {
-          System.err.println("Could not read file '"
-              + summaryFile.getAbsolutePath() + "'.");
-          e.printStackTrace();
-        }
-      }
-    }
-    this.cachedNodeStatuses = parsedNodeStatuses;
-  }
-
-  private SortedSet<String> listSummaryDocuments() {
-    if (this.cachedSummaryDocuments == null) {
-      this.cacheSummaryDocuments();
-    }
-    return new TreeSet<String>(this.cachedSummaryDocuments.keySet());
-  }
-
-  private void cacheSummaryDocuments() {
-    SortedMap<String, SummaryDocument> parsedSummaryDocuments =
-        new TreeMap<String, SummaryDocument>();
-    File directory = this.outDir;
-    if (directory != null) {
-      File summaryFile = new File(directory, "summary");
-      if (summaryFile.exists()) {
-        String line = null;
-        try {
-          Gson gson = new Gson();
-          BufferedReader br = new BufferedReader(new FileReader(
-              summaryFile));
-          while ((line = br.readLine()) != null) {
-            if (line.length() == 0) {
-              continue;
-            }
-            SummaryDocument summaryDocument = gson.fromJson(line,
-                SummaryDocument.class);
-            if (summaryDocument != null) {
-              parsedSummaryDocuments.put(summaryDocument.getFingerprint(),
-                  summaryDocument);
-            }
-          }
-          br.close();
-          this.listedFiles += parsedSummaryDocuments.size();
-          this.listOperations++;
-        } catch (IOException e) {
-          System.err.println("Could not read file '"
-              + summaryFile.getAbsolutePath() + "'.");
-          e.printStackTrace();
-        } catch (JsonParseException e) {
-          System.err.println("Could not parse summary document '" + line
-              + "' in file '" + summaryFile.getAbsolutePath() + "'.");
-          e.printStackTrace();
-        }
-      }
-    }
-    this.cachedSummaryDocuments = parsedSummaryDocuments;
-  }
-
-  private <T extends Document> SortedSet<String> listDocumentFiles(
-      Class<T> documentType) {
-    SortedSet<String> fingerprints = new TreeSet<String>();
-    File directory = null;
-    String subdirectory = null;
-    if (documentType.equals(DetailsStatus.class)) {
-      directory = this.statusDir;
-      subdirectory = "details";
-    } else if (documentType.equals(BandwidthStatus.class)) {
-      directory = this.statusDir;
-      subdirectory = "bandwidth";
-    } else if (documentType.equals(WeightsStatus.class)) {
-      directory = this.statusDir;
-      subdirectory = "weights";
-    } else if (documentType.equals(ClientsStatus.class)) {
-      directory = this.statusDir;
-      subdirectory = "clients";
-    } else if (documentType.equals(UptimeStatus.class)) {
-      directory = this.statusDir;
-      subdirectory = "uptimes";
-    } else if (documentType.equals(DetailsDocument.class)) {
-      directory = this.outDir;
-      subdirectory = "details";
-    } else if (documentType.equals(BandwidthDocument.class)) {
-      directory = this.outDir;
-      subdirectory = "bandwidth";
-    } else if (documentType.equals(WeightsDocument.class)) {
-      directory = this.outDir;
-      subdirectory = "weights";
-    } else if (documentType.equals(ClientsDocument.class)) {
-      directory = this.outDir;
-      subdirectory = "clients";
-    } else if (documentType.equals(UptimeDocument.class)) {
-      directory = this.outDir;
-      subdirectory = "uptimes";
-    }
-    if (directory != null && subdirectory != null) {
-      Stack<File> files = new Stack<File>();
-      files.add(new File(directory, subdirectory));
-      while (!files.isEmpty()) {
-        File file = files.pop();
-        if (file.isDirectory()) {
-          files.addAll(Arrays.asList(file.listFiles()));
-        } else if (file.getName().length() == 40) {
-            fingerprints.add(file.getName());
-        }
-      }
-    }
-    this.listOperations++;
-    this.listedFiles += fingerprints.size();
-    return fingerprints;
-  }
-
-  public <T extends Document> boolean store(T document) {
-    return this.store(document, null);
-  }
-
-  public <T extends Document> boolean store(T document,
-      String fingerprint) {
-    if (document instanceof NodeStatus) {
-      return this.storeNodeStatus((NodeStatus) document, fingerprint);
-    } else if (document instanceof SummaryDocument) {
-      return this.storeSummaryDocument((SummaryDocument) document,
-          fingerprint);
-    } else {
-      return this.storeDocumentFile(document, fingerprint);
-    }
-  }
-
-  private <T extends Document> boolean storeNodeStatus(
-      NodeStatus nodeStatus, String fingerprint) {
-    if (this.cachedNodeStatuses == null) {
-      this.cacheNodeStatuses();
-    }
-    this.cachedNodeStatuses.put(fingerprint, nodeStatus);
-    return true;
-  }
-
-  private <T extends Document> boolean storeSummaryDocument(
-      SummaryDocument summaryDocument, String fingerprint) {
-    if (this.cachedSummaryDocuments == null) {
-      this.cacheSummaryDocuments();
-    }
-    this.cachedSummaryDocuments.put(fingerprint, summaryDocument);
-    return true;
-  }
-
-  private <T extends Document> boolean storeDocumentFile(T document,
-      String fingerprint) {
-    File documentFile = this.getDocumentFile(document.getClass(),
-        fingerprint);
-    if (documentFile == null) {
-      return false;
-    }
-    String documentString;
-    if (document.getDocumentString() != null) {
-      documentString = document.getDocumentString();
-    } else if (document instanceof BandwidthDocument ||
-          document instanceof WeightsDocument ||
-          document instanceof ClientsDocument ||
-          document instanceof UptimeDocument) {
-      Gson gson = new Gson();
-      documentString = gson.toJson(document);
-    } else if (document instanceof DetailsStatus ||
-        document instanceof DetailsDocument) {
-      /* Don't escape HTML characters, like < and >, contained in
-       * strings. */
-      Gson gson = new GsonBuilder().disableHtmlEscaping().create();
-      /* We must ensure that details files only contain ASCII characters
-       * and no UTF-8 characters.  While UTF-8 characters are perfectly
-       * valid in JSON, this would break compatibility with existing files
-       * pretty badly.  We already make sure that all strings in details
-       * objects are escaped JSON, e.g., \u00F2.  When Gson serlializes
-       * this string, it escapes the \ to \\, hence writes \\u00F2.  We
-       * need to undo this and change \\u00F2 back to \u00F2. */
-      documentString = gson.toJson(document).replaceAll("\\\\\\\\u",
-          "\\\\u");
-      /* Existing details statuses don't contain opening and closing curly
-       * brackets, so we should remove them from new details statuses,
-       * too. */
-       if (document instanceof DetailsStatus) {
-         documentString = documentString.substring(
-             documentString.indexOf("{") + 1,
-             documentString.lastIndexOf("}"));
-       }
-    } else if (document instanceof BandwidthStatus ||
-        document instanceof WeightsStatus ||
-        document instanceof ClientsStatus ||
-        document instanceof UptimeStatus) {
-      documentString = document.toDocumentString();
-    } else {
-      System.err.println("Serializing is not supported for type "
-          + document.getClass().getName() + ".");
-      return false;
-    }
-    try {
-      documentFile.getParentFile().mkdirs();
-      File documentTempFile = new File(
-          documentFile.getAbsolutePath() + ".tmp");
-      BufferedWriter bw = new BufferedWriter(new FileWriter(
-          documentTempFile));
-      bw.write(documentString);
-      bw.close();
-      documentFile.delete();
-      documentTempFile.renameTo(documentFile);
-      this.storedFiles++;
-      this.storedBytes += documentString.length();
-    } catch (IOException e) {
-      System.err.println("Could not write file '"
-          + documentFile.getAbsolutePath() + "'.");
-      e.printStackTrace();
-      return false;
-    }
-    return true;
-  }
-
-  public <T extends Document> T retrieve(Class<T> documentType,
-      boolean parse) {
-    return this.retrieve(documentType, parse, null);
-  }
-
-  public <T extends Document> T retrieve(Class<T> documentType,
-      boolean parse, String fingerprint) {
-    if (documentType.equals(NodeStatus.class)) {
-      return documentType.cast(this.retrieveNodeStatus(fingerprint));
-    } else if (documentType.equals(SummaryDocument.class)) {
-      return documentType.cast(this.retrieveSummaryDocument(fingerprint));
-    } else {
-      return this.retrieveDocumentFile(documentType, parse, fingerprint);
-    }
-  }
-
-  private NodeStatus retrieveNodeStatus(String fingerprint) {
-    if (this.cachedNodeStatuses == null) {
-      this.cacheNodeStatuses();
-    }
-    return this.cachedNodeStatuses.get(fingerprint);
-  }
-
-  private SummaryDocument retrieveSummaryDocument(String fingerprint) {
-    if (this.cachedSummaryDocuments == null) {
-      this.cacheSummaryDocuments();
-    }
-    if (this.cachedSummaryDocuments.containsKey(fingerprint)) {
-      return this.cachedSummaryDocuments.get(fingerprint);
-    }
-    /* TODO This is an evil hack to support looking up relays or bridges
-     * that haven't been running for a week without having to load
-     * 500,000 NodeStatus instances into memory.  Maybe there's a better
-     * way?  Or do we need to switch to a real database for this? */
-    DetailsDocument detailsDocument = this.retrieveDocumentFile(
-        DetailsDocument.class, true, fingerprint);
-    if (detailsDocument == null) {
-      return null;
-    }
-    boolean isRelay = detailsDocument.getHashedFingerprint() == null;
-    boolean running = false;
-    String nickname = detailsDocument.getNickname();
-    List<String> addresses = new ArrayList<String>();
-    String countryCode = null, aSNumber = null, contact = null;
-    for (String orAddressAndPort : detailsDocument.getOrAddresses()) {
-      if (!orAddressAndPort.contains(":")) {
-        return null;
-      }
-      String orAddress = orAddressAndPort.substring(0,
-          orAddressAndPort.lastIndexOf(":"));
-      if (!addresses.contains(orAddress)) {
-        addresses.add(orAddress);
-      }
-    }
-    if (detailsDocument.getExitAddresses() != null) {
-      for (String exitAddress : detailsDocument.getExitAddresses()) {
-        if (!addresses.contains(exitAddress)) {
-          addresses.add(exitAddress);
-        }
-      }
-    }
-    SortedSet<String> relayFlags = new TreeSet<String>(), family = null;
-    long lastSeenMillis = -1L, consensusWeight = -1L,
-        firstSeenMillis = -1L;
-    SummaryDocument summaryDocument = new SummaryDocument(isRelay,
-        nickname, fingerprint, addresses, lastSeenMillis, running,
-        relayFlags, consensusWeight, countryCode, firstSeenMillis,
-        aSNumber, contact, family);
-    return summaryDocument;
-  }
-
-  private <T extends Document> T retrieveDocumentFile(
-      Class<T> documentType, boolean parse, String fingerprint) {
-    File documentFile = this.getDocumentFile(documentType, fingerprint);
-    if (documentFile == null || !documentFile.exists()) {
-      return null;
-    } else if (documentFile.isDirectory()) {
-      System.err.println("Could not read file '"
-          + documentFile.getAbsolutePath() + "', because it is a "
-          + "directory.");
-      return null;
-    }
-    String documentString = null;
-    try {
-      ByteArrayOutputStream baos = new ByteArrayOutputStream();
-      BufferedInputStream bis = new BufferedInputStream(
-          new FileInputStream(documentFile));
-      int len;
-      byte[] data = new byte[1024];
-      while ((len = bis.read(data, 0, 1024)) >= 0) {
-        baos.write(data, 0, len);
-      }
-      bis.close();
-      byte[] allData = baos.toByteArray();
-      if (allData.length == 0) {
-        return null;
-      }
-      documentString = new String(allData, "US-ASCII");
-      this.retrievedFiles++;
-      this.retrievedBytes += documentString.length();
-    } catch (IOException e) {
-      System.err.println("Could not read file '"
-          + documentFile.getAbsolutePath() + "'.");
-      e.printStackTrace();
-      return null;
-    }
-    T result = null;
-    if (!parse) {
-      return this.retrieveUnparsedDocumentFile(documentType,
-          documentString);
-    } else if (documentType.equals(DetailsDocument.class) ||
-        documentType.equals(BandwidthDocument.class) ||
-        documentType.equals(WeightsDocument.class) ||
-        documentType.equals(ClientsDocument.class) ||
-        documentType.equals(UptimeDocument.class)) {
-      return this.retrieveParsedDocumentFile(documentType,
-          documentString);
-    } else if (documentType.equals(BandwidthStatus.class) ||
-        documentType.equals(WeightsStatus.class) ||
-        documentType.equals(ClientsStatus.class) ||
-        documentType.equals(UptimeStatus.class)) {
-      return this.retrieveParsedStatusFile(documentType, documentString);
-    } else if (documentType.equals(DetailsStatus.class)) {
-      return this.retrieveParsedDocumentFile(documentType, "{"
-          + documentString + "}");
-    } else {
-      System.err.println("Parsing is not supported for type "
-          + documentType.getName() + ".");
-    }
-    return result;
-  }
-
-  private <T extends Document> T retrieveParsedStatusFile(
-      Class<T> documentType, String documentString) {
-    T result = null;
-    try {
-      result = documentType.newInstance();
-      result.fromDocumentString(documentString);
-    } catch (InstantiationException e) {
-      /* Handle below. */
-      e.printStackTrace();
-    } catch (IllegalAccessException e) {
-      /* Handle below. */
-      e.printStackTrace();
-    }
-    if (result == null) {
-      System.err.println("Could not initialize parsed status file of "
-          + "type " + documentType.getName() + ".");
-    }
-    return result;
-  }
-
-  private <T extends Document> T retrieveParsedDocumentFile(
-      Class<T> documentType, String documentString) {
-    T result = null;
-    Gson gson = new Gson();
-    try {
-      result = gson.fromJson(documentString, documentType);
-    } catch (JsonParseException e) {
-      /* Handle below. */
-      e.printStackTrace();
-    }
-    if (result == null) {
-      System.err.println("Could not initialize parsed document of type "
-          + documentType.getName() + ".");
-    }
-    return result;
-  }
-
-  private <T extends Document> T retrieveUnparsedDocumentFile(
-      Class<T> documentType, String documentString) {
-    T result = null;
-    try {
-      result = documentType.newInstance();
-      result.setDocumentString(documentString);
-    } catch (InstantiationException e) {
-      /* Handle below. */
-      e.printStackTrace();
-    } catch (IllegalAccessException e) {
-      /* Handle below. */
-      e.printStackTrace();
-    }
-    if (result == null) {
-      System.err.println("Could not initialize unparsed document of type "
-          + documentType.getName() + ".");
-    }
-    return result;
-  }
-
-  public <T extends Document> boolean remove(Class<T> documentType) {
-    return this.remove(documentType, null);
-  }
-
-  public <T extends Document> boolean remove(Class<T> documentType,
-      String fingerprint) {
-    if (documentType.equals(NodeStatus.class)) {
-      return this.removeNodeStatus(fingerprint);
-    } else if (documentType.equals(SummaryDocument.class)) {
-      return this.removeSummaryDocument(fingerprint);
-    } else {
-      return this.removeDocumentFile(documentType, fingerprint);
-    }
-  }
-
-  private boolean removeNodeStatus(String fingerprint) {
-    if (this.cachedNodeStatuses == null) {
-      this.cacheNodeStatuses();
-    }
-    return this.cachedNodeStatuses.remove(fingerprint) != null;
-  }
-
-  private boolean removeSummaryDocument(String fingerprint) {
-    if (this.cachedSummaryDocuments == null) {
-      this.cacheSummaryDocuments();
-    }
-    return this.cachedSummaryDocuments.remove(fingerprint) != null;
-  }
-
-  private <T extends Document> boolean removeDocumentFile(
-      Class<T> documentType, String fingerprint) {
-    File documentFile = this.getDocumentFile(documentType, fingerprint);
-    if (documentFile == null || !documentFile.delete()) {
-      System.err.println("Could not delete file '"
-          + documentFile.getAbsolutePath() + "'.");
-      return false;
-    }
-    this.removedFiles++;
-    return true;
-  }
-
-  private <T extends Document> File getDocumentFile(Class<T> documentType,
-      String fingerprint) {
-    File documentFile = null;
-    if (fingerprint == null && !documentType.equals(UpdateStatus.class) &&
-        !documentType.equals(UptimeStatus.class)) {
-      // TODO Instead of using the update file workaround, add new method
-      // lastModified(Class<T> documentType) that serves a similar
-      // purpose.
-      return null;
-    }
-    File directory = null;
-    String fileName = null;
-    if (documentType.equals(DetailsStatus.class)) {
-      directory = this.statusDir;
-      fileName = String.format("details/%s/%s/%s",
-          fingerprint.substring(0, 1), fingerprint.substring(1, 2),
-          fingerprint);
-    } else if (documentType.equals(BandwidthStatus.class)) {
-      directory = this.statusDir;
-      fileName = String.format("bandwidth/%s/%s/%s",
-          fingerprint.substring(0, 1), fingerprint.substring(1, 2),
-          fingerprint);
-    } else if (documentType.equals(WeightsStatus.class)) {
-      directory = this.statusDir;
-      fileName = String.format("weights/%s/%s/%s",
-          fingerprint.substring(0, 1), fingerprint.substring(1, 2),
-          fingerprint);
-    } else if (documentType.equals(ClientsStatus.class)) {
-      directory = this.statusDir;
-      fileName = String.format("clients/%s/%s/%s",
-          fingerprint.substring(0, 1), fingerprint.substring(1, 2),
-          fingerprint);
-    } else if (documentType.equals(UptimeStatus.class)) {
-      directory = this.statusDir;
-      if (fingerprint == null) {
-        fileName = "uptime";
-      } else {
-        fileName = String.format("uptimes/%s/%s/%s",
-            fingerprint.substring(0, 1), fingerprint.substring(1, 2),
-            fingerprint);
-      }
-    } else if (documentType.equals(UpdateStatus.class)) {
-      directory = this.outDir;
-      fileName = "update";
-    } else if (documentType.equals(DetailsDocument.class)) {
-      directory = this.outDir;
-      fileName = String.format("details/%s", fingerprint);
-    } else if (documentType.equals(BandwidthDocument.class)) {
-      directory = this.outDir;
-      fileName = String.format("bandwidth/%s", fingerprint);
-    } else if (documentType.equals(WeightsDocument.class)) {
-      directory = this.outDir;
-      fileName = String.format("weights/%s", fingerprint);
-    } else if (documentType.equals(ClientsDocument.class)) {
-      directory = this.outDir;
-      fileName = String.format("clients/%s", fingerprint);
-    } else if (documentType.equals(UptimeDocument.class)) {
-      directory = this.outDir;
-      fileName = String.format("uptimes/%s", fingerprint);
-    }
-    if (directory != null && fileName != null) {
-      documentFile = new File(directory, fileName);
-    }
-    return documentFile;
-  }
-
-  public void flushDocumentCache() {
-    /* Write cached node statuses to disk, and write update file
-     * containing current time.  It's important to write the update file
-     * now, not earlier, because the front-end should not read new node
-     * statuses until all details, bandwidths, and weights are ready. */
-    if (this.cachedNodeStatuses != null ||
-        this.cachedSummaryDocuments != null) {
-      if (this.cachedNodeStatuses != null) {
-        this.writeNodeStatuses();
-      }
-      if (this.cachedSummaryDocuments != null) {
-        this.writeSummaryDocuments();
-      }
-      this.writeUpdateStatus();
-    }
-  }
-
-  private void writeNodeStatuses() {
-    File directory = this.statusDir;
-    if (directory == null) {
-      return;
-    }
-    File summaryFile = new File(directory, "summary");
-    SortedMap<String, NodeStatus>
-        cachedRelays = new TreeMap<String, NodeStatus>(),
-        cachedBridges = new TreeMap<String, NodeStatus>();
-    for (Map.Entry<String, NodeStatus> e :
-        this.cachedNodeStatuses.entrySet()) {
-      if (e.getValue().isRelay()) {
-        cachedRelays.put(e.getKey(), e.getValue());
-      } else {
-        cachedBridges.put(e.getKey(), e.getValue());
-      }
-    }
-    StringBuilder sb = new StringBuilder();
-    for (NodeStatus relay : cachedRelays.values()) {
-      String line = relay.toString();
-      if (line != null) {
-        sb.append(line + "\n");
-      } else {
-        System.err.println("Could not serialize relay node status '"
-            + relay.getFingerprint() + "'");
-      }
-    }
-    for (NodeStatus bridge : cachedBridges.values()) {
-      String line = bridge.toString();
-      if (line != null) {
-        sb.append(line + "\n");
-      } else {
-        System.err.println("Could not serialize bridge node status '"
-            + bridge.getFingerprint() + "'");
-      }
-    }
-    String documentString = sb.toString();
-    try {
-      summaryFile.getParentFile().mkdirs();
-      BufferedWriter bw = new BufferedWriter(new FileWriter(summaryFile));
-      bw.write(documentString);
-      bw.close();
-      this.storedFiles++;
-      this.storedBytes += documentString.length();
-    } catch (IOException e) {
-      System.err.println("Could not write file '"
-          + summaryFile.getAbsolutePath() + "'.");
-      e.printStackTrace();
-    }
-  }
-
-  private void writeSummaryDocuments() {
-    StringBuilder sb = new StringBuilder();
-    Gson gson = new Gson();
-    for (SummaryDocument summaryDocument :
-        this.cachedSummaryDocuments.values()) {
-      String line = gson.toJson(summaryDocument);
-      if (line != null) {
-        sb.append(line + "\n");
-      } else {
-        System.err.println("Could not serialize relay summary document '"
-            + summaryDocument.getFingerprint() + "'");
-      }
-    }
-    String documentString = sb.toString();
-    File summaryFile = new File(this.outDir, "summary");
-    try {
-      summaryFile.getParentFile().mkdirs();
-      BufferedWriter bw = new BufferedWriter(new FileWriter(summaryFile));
-      bw.write(documentString);
-      bw.close();
-      this.storedFiles++;
-      this.storedBytes += documentString.length();
-    } catch (IOException e) {
-      System.err.println("Could not write file '"
-          + summaryFile.getAbsolutePath() + "'.");
-      e.printStackTrace();
-    }
-  }
-
-  private void writeUpdateStatus() {
-    if (this.outDir == null) {
-      return;
-    }
-    File updateFile = new File(this.outDir, "update");
-    String documentString = String.valueOf(this.time.currentTimeMillis());
-    try {
-      updateFile.getParentFile().mkdirs();
-      BufferedWriter bw = new BufferedWriter(new FileWriter(updateFile));
-      bw.write(documentString);
-      bw.close();
-      this.storedFiles++;
-      this.storedBytes += documentString.length();
-    } catch (IOException e) {
-      System.err.println("Could not write file '"
-          + updateFile.getAbsolutePath() + "'.");
-      e.printStackTrace();
-    }
-  }
-
-  public String getStatsString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append("    " + Logger.formatDecimalNumber(listOperations)
-        + " list operations performed\n");
-    sb.append("    " + Logger.formatDecimalNumber(listedFiles)
-        + " files listed\n");
-    sb.append("    " + Logger.formatDecimalNumber(storedFiles)
-        + " files stored\n");
-    sb.append("    " + Logger.formatBytes(storedBytes) + " stored\n");
-    sb.append("    " + Logger.formatDecimalNumber(retrievedFiles)
-        + " files retrieved\n");
-    sb.append("    " + Logger.formatBytes(retrievedBytes)
-        + " retrieved\n");
-    sb.append("    " + Logger.formatDecimalNumber(removedFiles)
-        + " files removed\n");
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/DocumentWriter.java b/src/org/torproject/onionoo/DocumentWriter.java
deleted file mode 100644
index 4cdeef9..0000000
--- a/src/org/torproject/onionoo/DocumentWriter.java
+++ /dev/null
@@ -1,11 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-public interface DocumentWriter {
-
-  public abstract void writeDocuments();
-
-  public abstract String getStatsString();
-}
-
diff --git a/src/org/torproject/onionoo/GraphHistory.java b/src/org/torproject/onionoo/GraphHistory.java
deleted file mode 100644
index f03be58..0000000
--- a/src/org/torproject/onionoo/GraphHistory.java
+++ /dev/null
@@ -1,56 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.List;
-
-public class GraphHistory {
-
-  private String first;
-  public void setFirst(String first) {
-    this.first = first;
-  }
-  public String getFirst() {
-    return this.first;
-  }
-
-  private String last;
-  public void setLast(String last) {
-    this.last = last;
-  }
-  public String getLast() {
-    return this.last;
-  }
-
-  private Integer interval;
-  public void setInterval(Integer interval) {
-    this.interval = interval;
-  }
-  public Integer getInterval() {
-    return this.interval;
-  }
-
-  private Double factor;
-  public void setFactor(Double factor) {
-    this.factor = factor;
-  }
-  public Double getFactor() {
-    return this.factor;
-  }
-
-  private Integer count;
-  public void setCount(Integer count) {
-    this.count = count;
-  }
-  public Integer getCount() {
-    return this.count;
-  }
-
-  private List<Integer> values;
-  public void setValues(List<Integer> values) {
-    this.values = values;
-  }
-  public List<Integer> getValues() {
-    return this.values;
-  }
-}
diff --git a/src/org/torproject/onionoo/LockFile.java b/src/org/torproject/onionoo/LockFile.java
deleted file mode 100644
index 768db53..0000000
--- a/src/org/torproject/onionoo/LockFile.java
+++ /dev/null
@@ -1,43 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileWriter;
-import java.io.IOException;
-
-public class LockFile {
-
-  private final File lockFile = new File("lock");
-
-  public boolean acquireLock() {
-    Time time = ApplicationFactory.getTime();
-    try {
-      if (this.lockFile.exists()) {
-        return false;
-      }
-      if (this.lockFile.getParentFile() != null) {
-        this.lockFile.getParentFile().mkdirs();
-      }
-      BufferedWriter bw = new BufferedWriter(new FileWriter(
-          this.lockFile));
-      bw.append("" + time.currentTimeMillis() + "\n");
-      bw.close();
-      return true;
-    } catch (IOException e) {
-      System.err.println("Caught exception while trying to acquire "
-          + "lock!");
-      e.printStackTrace();
-      return false;
-    }
-  }
-
-  public boolean releaseLock() {
-    if (this.lockFile.exists()) {
-      this.lockFile.delete();
-    }
-    return !this.lockFile.exists();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/Logger.java b/src/org/torproject/onionoo/Logger.java
deleted file mode 100644
index 6906a6a..0000000
--- a/src/org/torproject/onionoo/Logger.java
+++ /dev/null
@@ -1,81 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Date;
-
-public class Logger {
-
-  private Logger() {
-  }
-
-  private static Time time;
-
-  public static void setTime() {
-    time = ApplicationFactory.getTime();
-  }
-
-  private static long currentTimeMillis() {
-    if (time == null) {
-      return System.currentTimeMillis();
-    } else {
-      return time.currentTimeMillis();
-    }
-  }
-
-  public static String formatDecimalNumber(long decimalNumber) {
-    return String.format("%,d", decimalNumber);
-  }
-
-  public static String formatMillis(long millis) {
-    return String.format("%02d:%02d.%03d minutes",
-        millis / DateTimeHelper.ONE_MINUTE,
-        (millis % DateTimeHelper.ONE_MINUTE) / DateTimeHelper.ONE_SECOND,
-        millis % DateTimeHelper.ONE_SECOND);
-  }
-
-  public static String formatBytes(long bytes) {
-    if (bytes < 1024) {
-      return bytes + " B";
-    } else {
-      int exp = (int) (Math.log(bytes) / Math.log(1024));
-      return String.format("%.1f %siB", bytes / Math.pow(1024, exp),
-          "KMGTPE".charAt(exp-1));
-    }
-  }
-
-  private static long printedLastStatusMessage = -1L;
-
-  public static void printStatus(String message) {
-    System.out.println(new Date() + ": " + message);
-    printedLastStatusMessage = currentTimeMillis();
-  }
-
-  public static void printStatistics(String component, String message) {
-    System.out.print("  " + component + " statistics:\n" + message);
-  }
-
-  public static void printStatusTime(String message) {
-    printStatusOrErrorTime(message, false);
-  }
-
-  public static void printErrorTime(String message) {
-    printStatusOrErrorTime(message, true);
-  }
-
-  private static void printStatusOrErrorTime(String message,
-      boolean printToSystemErr) {
-    long now = currentTimeMillis();
-    long millis = printedLastStatusMessage < 0 ? 0 :
-        now - printedLastStatusMessage;
-    String line = "  " + message + " (" + Logger.formatMillis(millis)
-        + ").";
-    if (printToSystemErr) {
-      System.err.println(line);
-    } else {
-      System.out.println(line);
-    }
-    printedLastStatusMessage = now;
-  }
-}
-
diff --git a/src/org/torproject/onionoo/LookupService.java b/src/org/torproject/onionoo/LookupService.java
deleted file mode 100644
index a968bc0..0000000
--- a/src/org/torproject/onionoo/LookupService.java
+++ /dev/null
@@ -1,408 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-import java.util.regex.Pattern;
-
-class LookupResult {
-
-  private String countryCode;
-  public void setCountryCode(String countryCode) {
-    this.countryCode = countryCode;
-  }
-  public String getCountryCode() {
-    return this.countryCode;
-  }
-
-  private String countryName;
-  public void setCountryName(String countryName) {
-    this.countryName = countryName;
-  }
-  public String getCountryName() {
-    return this.countryName;
-  }
-
-  private String regionName;
-  public void setRegionName(String regionName) {
-    this.regionName = regionName;
-  }
-  public String getRegionName() {
-    return this.regionName;
-  }
-
-  private String cityName;
-  public void setCityName(String cityName) {
-    this.cityName = cityName;
-  }
-  public String getCityName() {
-    return this.cityName;
-  }
-
-  private Float latitude;
-  public void setLatitude(Float latitude) {
-    this.latitude = latitude;
-  }
-  public Float getLatitude() {
-    return this.latitude;
-  }
-
-  private Float longitude;
-  public void setLongitude(Float longitude) {
-    this.longitude = longitude;
-  }
-  public Float getLongitude() {
-    return this.longitude;
-  }
-
-  private String asNumber;
-  public void setAsNumber(String asNumber) {
-    this.asNumber = asNumber;
-  }
-  public String getAsNumber() {
-    return this.asNumber;
-  }
-
-  private String asName;
-  public void setAsName(String asName) {
-    this.asName = asName;
-  }
-  public String getAsName() {
-    return this.asName;
-  }
-}
-
-public class LookupService {
-
-  private File geoipDir;
-  private File geoLite2CityBlocksCsvFile;
-  private File geoLite2CityLocationsCsvFile;
-  private File geoIPASNum2CsvFile;
-  private boolean hasAllFiles = false;
-  public LookupService(File geoipDir) {
-    this.geoipDir = geoipDir;
-    this.findRequiredCsvFiles();
-  }
-
-  /* Make sure we have all required .csv files. */
-  private void findRequiredCsvFiles() {
-    this.geoLite2CityBlocksCsvFile = new File(this.geoipDir,
-        "GeoLite2-City-Blocks.csv");
-    if (!this.geoLite2CityBlocksCsvFile.exists()) {
-      System.err.println("No GeoLite2-City-Blocks.csv file in geoip/.");
-      return;
-    }
-    this.geoLite2CityLocationsCsvFile = new File(this.geoipDir,
-        "GeoLite2-City-Locations.csv");
-    if (!this.geoLite2CityLocationsCsvFile.exists()) {
-      System.err.println("No GeoLite2-City-Locations.csv file in "
-          + "geoip/.");
-      return;
-    }
-    this.geoIPASNum2CsvFile = new File(this.geoipDir, "GeoIPASNum2.csv");
-    if (!this.geoIPASNum2CsvFile.exists()) {
-      System.err.println("No GeoIPASNum2.csv file in geoip/.");
-      return;
-    }
-    this.hasAllFiles = true;
-  }
-
-  private Pattern ipv4Pattern = Pattern.compile("^[0-9\\.]{7,15}$");
-  private long parseAddressString(String addressString) {
-    long addressNumber = -1L;
-    if (ipv4Pattern.matcher(addressString).matches()) {
-      String[] parts = addressString.split("\\.", 4);
-      if (parts.length == 4) {
-        addressNumber = 0L;
-        for (int i = 0; i < 4; i++) {
-          addressNumber *= 256L;
-          int octetValue = -1;
-          try {
-            octetValue = Integer.parseInt(parts[i]);
-          } catch (NumberFormatException e) {
-          }
-          if (octetValue < 0 || octetValue > 255) {
-            addressNumber = -1L;
-            break;
-          }
-          addressNumber += octetValue;
-        }
-      }
-    }
-    return addressNumber;
-  }
-
-  public SortedMap<String, LookupResult> lookup(
-      SortedSet<String> addressStrings) {
-
-    SortedMap<String, LookupResult> lookupResults =
-        new TreeMap<String, LookupResult>();
-
-    if (!this.hasAllFiles) {
-      return lookupResults;
-    }
-
-    /* Obtain a map from relay IP address strings to numbers. */
-    Map<String, Long> addressStringNumbers = new HashMap<String, Long>();
-    for (String addressString : addressStrings) {
-      long addressNumber = this.parseAddressString(addressString);
-      if (addressNumber >= 0L) {
-        addressStringNumbers.put(addressString, addressNumber);
-      }
-    }
-    if (addressStringNumbers.isEmpty()) {
-      return lookupResults;
-    }
-
-    /* Obtain a map from IP address numbers to blocks and to latitudes and
-       longitudes. */
-    Map<Long, Long> addressNumberBlocks = new HashMap<Long, Long>();
-    Map<Long, Float[]> addressNumberLatLong =
-        new HashMap<Long, Float[]>();
-    try {
-      SortedSet<Long> sortedAddressNumbers = new TreeSet<Long>(
-          addressStringNumbers.values());
-      BufferedReader br = new BufferedReader(new InputStreamReader(
-          new FileInputStream(geoLite2CityBlocksCsvFile), "ISO-8859-1"));
-      String line = br.readLine();
-      while ((line = br.readLine()) != null) {
-        if (!line.startsWith("::ffff:")) {
-          /* TODO Make this less hacky and IPv6-ready at some point. */
-          continue;
-        }
-        String[] parts = line.replaceAll("\"", "").split(",", 10);
-        if (parts.length != 10) {
-          System.err.println("Illegal line '" + line + "' in "
-              + geoLite2CityBlocksCsvFile.getAbsolutePath() + ".");
-          br.close();
-          return lookupResults;
-        }
-        try {
-          String startAddressString = parts[0].substring(7); /* ::ffff: */
-          long startIpNum = this.parseAddressString(startAddressString);
-          if (startIpNum < 0L) {
-            System.err.println("Illegal IP address in '" + line
-                + "' in " + geoLite2CityBlocksCsvFile.getAbsolutePath()
-                + ".");
-            br.close();
-            return lookupResults;
-          }
-          int networkMaskLength = Integer.parseInt(parts[1]);
-          if (networkMaskLength < 96 || networkMaskLength > 128) {
-            System.err.println("Illegal network mask in '" + line
-                + "' in " + geoLite2CityBlocksCsvFile.getAbsolutePath()
-                + ".");
-            br.close();
-            return lookupResults;
-          }
-          if (parts[2].length() == 0 && parts[3].length() == 0) {
-            continue;
-          }
-          long endIpNum = startIpNum + (1 << (128 - networkMaskLength))
-              - 1;
-          for (long addressNumber : sortedAddressNumbers.
-              tailSet(startIpNum).headSet(endIpNum + 1L)) {
-            String blockString = parts[2].length() > 0 ? parts[2] :
-                parts[3];
-            long blockNumber = Long.parseLong(blockString);
-            addressNumberBlocks.put(addressNumber, blockNumber);
-            if (parts[6].length() > 0 && parts[7].length() > 0) {
-              addressNumberLatLong.put(addressNumber,
-                  new Float[] { Float.parseFloat(parts[6]),
-                  Float.parseFloat(parts[7]) });
-            }
-          }
-        } catch (NumberFormatException e) {
-          System.err.println("Number format exception while parsing line "
-              + "'" + line + "' in "
-              + geoLite2CityBlocksCsvFile.getAbsolutePath() + ".");
-          br.close();
-          return lookupResults;
-        }
-      }
-      br.close();
-    } catch (IOException e) {
-      System.err.println("I/O exception while reading "
-          + geoLite2CityBlocksCsvFile.getAbsolutePath() + ".");
-      return lookupResults;
-    }
-
-    /* Obtain a map from relevant blocks to location lines. */
-    Map<Long, String> blockLocations = new HashMap<Long, String>();
-    try {
-      Set<Long> blockNumbers = new HashSet<Long>(
-          addressNumberBlocks.values());
-      BufferedReader br = new BufferedReader(new InputStreamReader(
-          new FileInputStream(geoLite2CityLocationsCsvFile),
-          "ISO-8859-1"));
-      String line = br.readLine();
-      while ((line = br.readLine()) != null) {
-        String[] parts = line.replaceAll("\"", "").split(",", 10);
-        if (parts.length != 10) {
-          System.err.println("Illegal line '" + line + "' in "
-              + geoLite2CityLocationsCsvFile.getAbsolutePath() + ".");
-          br.close();
-          return lookupResults;
-        }
-        try {
-          long locId = Long.parseLong(parts[0]);
-          if (blockNumbers.contains(locId)) {
-            blockLocations.put(locId, line);
-          }
-        } catch (NumberFormatException e) {
-          System.err.println("Number format exception while parsing line "
-              + "'" + line + "' in "
-              + geoLite2CityLocationsCsvFile.getAbsolutePath() + ".");
-          br.close();
-          return lookupResults;
-        }
-      }
-      br.close();
-    } catch (IOException e) {
-      System.err.println("I/O exception while reading "
-          + geoLite2CityLocationsCsvFile.getAbsolutePath() + ".");
-      return lookupResults;
-    }
-
-    /* Obtain a map from IP address numbers to ASN. */
-    Map<Long, String> addressNumberASN = new HashMap<Long, String>();
-    try {
-      SortedSet<Long> sortedAddressNumbers = new TreeSet<Long>(
-          addressStringNumbers.values());
-      long firstAddressNumber = sortedAddressNumbers.first();
-      BufferedReader br = new BufferedReader(new InputStreamReader(
-          new FileInputStream(geoIPASNum2CsvFile), "ISO-8859-1"));
-      String line;
-      long previousStartIpNum = -1L;
-      while ((line = br.readLine()) != null) {
-        String[] parts = line.replaceAll("\"", "").split(",", 3);
-        if (parts.length != 3) {
-          System.err.println("Illegal line '" + line + "' in "
-              + geoIPASNum2CsvFile.getAbsolutePath() + ".");
-          br.close();
-          return lookupResults;
-        }
-        try {
-          long startIpNum = Long.parseLong(parts[0]);
-          if (startIpNum <= previousStartIpNum) {
-            System.err.println("Line '" + line + "' not sorted in "
-                + geoIPASNum2CsvFile.getAbsolutePath() + ".");
-            br.close();
-            return lookupResults;
-          }
-          previousStartIpNum = startIpNum;
-          while (firstAddressNumber < startIpNum &&
-              firstAddressNumber != -1L) {
-            sortedAddressNumbers.remove(firstAddressNumber);
-            if (sortedAddressNumbers.isEmpty()) {
-              firstAddressNumber = -1L;
-            } else {
-              firstAddressNumber = sortedAddressNumbers.first();
-            }
-          }
-          long endIpNum = Long.parseLong(parts[1]);
-          while (firstAddressNumber <= endIpNum &&
-              firstAddressNumber != -1L) {
-            if (parts[2].startsWith("AS") &&
-                parts[2].split(" ", 2).length == 2) {
-              addressNumberASN.put(firstAddressNumber, parts[2]);
-            }
-            sortedAddressNumbers.remove(firstAddressNumber);
-            if (sortedAddressNumbers.isEmpty()) {
-              firstAddressNumber = -1L;
-            } else {
-              firstAddressNumber = sortedAddressNumbers.first();
-            }
-          }
-          if (firstAddressNumber == -1L) {
-            break;
-          }
-        }
-        catch (NumberFormatException e) {
-          System.err.println("Number format exception while parsing line "
-              + "'" + line + "' in "
-              + geoIPASNum2CsvFile.getAbsolutePath() + ".");
-          br.close();
-          return lookupResults;
-        }
-      }
-      br.close();
-    } catch (IOException e) {
-      System.err.println("I/O exception while reading "
-          + geoIPASNum2CsvFile.getAbsolutePath() + ".");
-      return lookupResults;
-    }
-
-    /* Finally, put together lookup results. */
-    for (String addressString : addressStrings) {
-      if (!addressStringNumbers.containsKey(addressString)) {
-        continue;
-      }
-      long addressNumber = addressStringNumbers.get(addressString);
-      if (!addressNumberBlocks.containsKey(addressNumber) &&
-          !addressNumberLatLong.containsKey(addressNumber) &&
-          !addressNumberASN.containsKey(addressNumber)) {
-        continue;
-      }
-      LookupResult lookupResult = new LookupResult();
-      if (addressNumberBlocks.containsKey(addressNumber)) {
-        long blockNumber = addressNumberBlocks.get(addressNumber);
-        if (blockLocations.containsKey(blockNumber)) {
-          String[] parts = blockLocations.get(blockNumber).
-              replaceAll("\"", "").split(",", -1);
-          lookupResult.setCountryCode(parts[3].toLowerCase());
-          if (parts[4].length() > 0) {
-            lookupResult.setCountryName(parts[4]);
-          }
-          if (parts[6].length() > 0) {
-            lookupResult.setRegionName(parts[6]);
-          }
-          if (parts[7].length() > 0) {
-            lookupResult.setCityName(parts[7]);
-          }
-        }
-      }
-      if (addressNumberLatLong.containsKey(addressNumber)) {
-        Float[] latLong = addressNumberLatLong.get(addressNumber);
-        lookupResult.setLatitude(latLong[0]);
-        lookupResult.setLongitude(latLong[1]);
-      }
-      if (addressNumberASN.containsKey(addressNumber)) {
-        String[] parts = addressNumberASN.get(addressNumber).split(" ",
-            2);
-        lookupResult.setAsNumber(parts[0]);
-        lookupResult.setAsName(parts[1]);
-      }
-      lookupResults.put(addressString, lookupResult);
-    }
-
-    /* Keep statistics. */
-    this.addressesLookedUp += addressStrings.size();
-    this.addressesResolved += lookupResults.size();
-
-    return lookupResults;
-  }
-
-  private int addressesLookedUp = 0, addressesResolved = 0;
-
-  public String getStatsString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append("    " + Logger.formatDecimalNumber(addressesLookedUp)
-        + " addresses looked up\n");
-    sb.append("    " + Logger.formatDecimalNumber(addressesResolved)
-        + " addresses resolved\n");
-    return sb.toString();
-  }
-}
diff --git a/src/org/torproject/onionoo/Main.java b/src/org/torproject/onionoo/Main.java
deleted file mode 100644
index e9afe2f..0000000
--- a/src/org/torproject/onionoo/Main.java
+++ /dev/null
@@ -1,119 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.io.File;
-
-/* Update search data and status data files. */
-public class Main {
-
-  private Main() {
-  }
-
-  public static void main(String[] args) {
-
-    LockFile lf = new LockFile();
-    Logger.setTime();
-    Logger.printStatus("Initializing.");
-    if (lf.acquireLock()) {
-      Logger.printStatusTime("Acquired lock");
-    } else {
-      Logger.printErrorTime("Could not acquire lock.  Is Onionoo "
-          + "already running?  Terminating");
-      return;
-    }
-
-    DescriptorSource dso = ApplicationFactory.getDescriptorSource();
-    Logger.printStatusTime("Initialized descriptor source");
-    DocumentStore ds = ApplicationFactory.getDocumentStore();
-    Logger.printStatusTime("Initialized document store");
-    LookupService ls = new LookupService(new File("geoip"));
-    Logger.printStatusTime("Initialized Geoip lookup service");
-    ReverseDomainNameResolver rdnr = new ReverseDomainNameResolver();
-    Logger.printStatusTime("Initialized reverse domain name resolver");
-    NodeDetailsStatusUpdater ndsu = new NodeDetailsStatusUpdater(rdnr,
-        ls);
-    Logger.printStatusTime("Initialized node data writer");
-    BandwidthStatusUpdater bsu = new BandwidthStatusUpdater();
-    Logger.printStatusTime("Initialized bandwidth status updater");
-    WeightsStatusUpdater wsu = new WeightsStatusUpdater();
-    Logger.printStatusTime("Initialized weights status updater");
-    ClientsStatusUpdater csu = new ClientsStatusUpdater();
-    Logger.printStatusTime("Initialized clients status updater");
-    UptimeStatusUpdater usu = new UptimeStatusUpdater();
-    Logger.printStatusTime("Initialized uptime status updater");
-    StatusUpdater[] sus = new StatusUpdater[] { ndsu, bsu, wsu, csu,
-        usu };
-
-    SummaryDocumentWriter sdw = new SummaryDocumentWriter();
-    Logger.printStatusTime("Initialized summary document writer");
-    DetailsDocumentWriter ddw = new DetailsDocumentWriter();
-    Logger.printStatusTime("Initialized details document writer");
-    BandwidthDocumentWriter bdw = new BandwidthDocumentWriter();
-    Logger.printStatusTime("Initialized bandwidth document writer");
-    WeightsDocumentWriter wdw = new WeightsDocumentWriter();
-    Logger.printStatusTime("Initialized weights document writer");
-    ClientsDocumentWriter cdw = new ClientsDocumentWriter();
-    Logger.printStatusTime("Initialized clients document writer");
-    UptimeDocumentWriter udw = new UptimeDocumentWriter();
-    Logger.printStatusTime("Initialized uptime document writer");
-    DocumentWriter[] dws = new DocumentWriter[] { sdw, ddw, bdw, wdw, cdw,
-        udw };
-
-    Logger.printStatus("Downloading descriptors.");
-    dso.downloadDescriptors();
-
-    Logger.printStatus("Reading descriptors.");
-    dso.readDescriptors();
-
-    Logger.printStatus("Updating internal status files.");
-    for (StatusUpdater su : sus) {
-      su.updateStatuses();
-      Logger.printStatusTime(su.getClass().getSimpleName()
-          + " updated status files");
-    }
-
-    Logger.printStatus("Updating document files.");
-    for (DocumentWriter dw : dws) {
-      dw.writeDocuments();
-    }
-
-    Logger.printStatus("Shutting down.");
-    dso.writeHistoryFiles();
-    Logger.printStatusTime("Wrote parse histories");
-    ds.flushDocumentCache();
-    Logger.printStatusTime("Flushed document cache");
-
-    Logger.printStatus("Gathering statistics.");
-    for (StatusUpdater su : sus) {
-      String statsString = su.getStatsString();
-      if (statsString != null) {
-        Logger.printStatistics(su.getClass().getSimpleName(),
-            statsString);
-      }
-    }
-    for (DocumentWriter dw : dws) {
-      String statsString = dw.getStatsString();
-      if (statsString != null) {
-        Logger.printStatistics(dw.getClass().getSimpleName(),
-            statsString);
-      }
-    }
-    Logger.printStatistics("Descriptor source", dso.getStatsString());
-    Logger.printStatistics("Document store", ds.getStatsString());
-    Logger.printStatistics("GeoIP lookup service", ls.getStatsString());
-    Logger.printStatistics("Reverse domain name resolver",
-        rdnr.getStatsString());
-
-    Logger.printStatus("Releasing lock.");
-    if (lf.releaseLock()) {
-      Logger.printStatusTime("Released lock");
-    } else {
-      Logger.printErrorTime("Could not release lock.  The next "
-          + "execution may not start as expected");
-    }
-
-    Logger.printStatus("Terminating.");
-  }
-}
-
diff --git a/src/org/torproject/onionoo/NodeDetailsStatusUpdater.java b/src/org/torproject/onionoo/NodeDetailsStatusUpdater.java
deleted file mode 100644
index a1149a5..0000000
--- a/src/org/torproject/onionoo/NodeDetailsStatusUpdater.java
+++ /dev/null
@@ -1,620 +0,0 @@
-/* Copyright 2011--2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-import org.torproject.descriptor.BridgeNetworkStatus;
-import org.torproject.descriptor.BridgePoolAssignment;
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.ExitList;
-import org.torproject.descriptor.ExitListEntry;
-import org.torproject.descriptor.NetworkStatusEntry;
-import org.torproject.descriptor.RelayNetworkStatusConsensus;
-import org.torproject.descriptor.ServerDescriptor;
-
-public class NodeDetailsStatusUpdater implements DescriptorListener,
-    StatusUpdater {
-
-  private DescriptorSource descriptorSource;
-
-  private ReverseDomainNameResolver reverseDomainNameResolver;
-
-  private LookupService lookupService;
-
-  private DocumentStore documentStore;
-
-  private long now;
-
-  private SortedMap<String, NodeStatus> knownNodes =
-      new TreeMap<String, NodeStatus>();
-
-  private SortedMap<String, NodeStatus> relays;
-
-  private SortedMap<String, NodeStatus> bridges;
-
-  private long relaysLastValidAfterMillis = -1L;
-
-  private long bridgesLastPublishedMillis = -1L;
-
-  private SortedMap<String, Integer> lastBandwidthWeights = null;
-
-  private int relayConsensusesProcessed = 0, bridgeStatusesProcessed = 0;
-
-  public NodeDetailsStatusUpdater(
-      ReverseDomainNameResolver reverseDomainNameResolver,
-      LookupService lookupService) {
-    this.descriptorSource = ApplicationFactory.getDescriptorSource();
-    this.reverseDomainNameResolver = reverseDomainNameResolver;
-    this.lookupService = lookupService;
-    this.documentStore = ApplicationFactory.getDocumentStore();
-    this.now = ApplicationFactory.getTime().currentTimeMillis();
-    this.registerDescriptorListeners();
-  }
-
-  private void registerDescriptorListeners() {
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.RELAY_CONSENSUSES);
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.RELAY_SERVER_DESCRIPTORS);
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.BRIDGE_STATUSES);
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.BRIDGE_SERVER_DESCRIPTORS);
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.BRIDGE_POOL_ASSIGNMENTS);
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.EXIT_LISTS);
-  }
-
-  public void processDescriptor(Descriptor descriptor, boolean relay) {
-    if (descriptor instanceof ServerDescriptor && relay) {
-      this.processRelayServerDescriptor((ServerDescriptor) descriptor);
-    } else if (descriptor instanceof ExitList) {
-      this.processExitList((ExitList) descriptor);
-    } else if (descriptor instanceof RelayNetworkStatusConsensus) {
-      this.processRelayNetworkStatusConsensus(
-          (RelayNetworkStatusConsensus) descriptor);
-    } else if (descriptor instanceof ServerDescriptor && !relay) {
-      this.processBridgeServerDescriptor((ServerDescriptor) descriptor);
-    } else if (descriptor instanceof BridgePoolAssignment) {
-      this.processBridgePoolAssignment((BridgePoolAssignment) descriptor);
-    } else if (descriptor instanceof BridgeNetworkStatus) {
-      this.processBridgeNetworkStatus((BridgeNetworkStatus) descriptor);
-    }
-  }
-
-  private void processRelayServerDescriptor(
-      ServerDescriptor descriptor) {
-    String fingerprint = descriptor.getFingerprint();
-    DetailsStatus detailsStatus = this.documentStore.retrieve(
-        DetailsStatus.class, true, fingerprint);
-    String publishedDateTime =
-        DateTimeHelper.format(descriptor.getPublishedMillis());
-    if (detailsStatus == null) {
-      detailsStatus = new DetailsStatus();
-    } else if (detailsStatus.getDescPublished() != null &&
-        publishedDateTime.compareTo(
-            detailsStatus.getDescPublished()) < 0) {
-      return;
-    }
-    String lastRestartedString = DateTimeHelper.format(
-        descriptor.getPublishedMillis() - descriptor.getUptime()
-        * DateTimeHelper.ONE_SECOND);
-    int bandwidthRate = descriptor.getBandwidthRate();
-    int bandwidthBurst = descriptor.getBandwidthBurst();
-    int observedBandwidth = descriptor.getBandwidthObserved();
-    int advertisedBandwidth = Math.min(bandwidthRate,
-        Math.min(bandwidthBurst, observedBandwidth));
-    detailsStatus.setDescPublished(publishedDateTime);
-    detailsStatus.setLastRestarted(lastRestartedString);
-    detailsStatus.setBandwidthRate(bandwidthRate);
-    detailsStatus.setBandwidthBurst(bandwidthBurst);
-    detailsStatus.setObservedBandwidth(observedBandwidth);
-    detailsStatus.setAdvertisedBandwidth(advertisedBandwidth);
-    detailsStatus.setExitPolicy(descriptor.getExitPolicyLines());
-    detailsStatus.setContact(descriptor.getContact());
-    detailsStatus.setPlatform(descriptor.getPlatform());
-    detailsStatus.setFamily(descriptor.getFamilyEntries());
-    if (descriptor.getIpv6DefaultPolicy() != null &&
-        (descriptor.getIpv6DefaultPolicy().equals("accept") ||
-        descriptor.getIpv6DefaultPolicy().equals("reject")) &&
-        descriptor.getIpv6PortList() != null) {
-      Map<String, List<String>> exitPolicyV6Summary =
-          new HashMap<String, List<String>>();
-      List<String> portsOrPortRanges = Arrays.asList(
-          descriptor.getIpv6PortList().split(","));
-      exitPolicyV6Summary.put(descriptor.getIpv6DefaultPolicy(),
-          portsOrPortRanges);
-      detailsStatus.setExitPolicyV6Summary(exitPolicyV6Summary);
-    }
-    if (descriptor.isHibernating()) {
-      detailsStatus.setHibernating(true);
-    }
-    this.documentStore.store(detailsStatus, fingerprint);
-  }
-
-  private Map<String, Map<String, Long>> exitListEntries =
-      new HashMap<String, Map<String, Long>>();
-
-  private void processExitList(ExitList exitList) {
-    for (ExitListEntry exitListEntry : exitList.getExitListEntries()) {
-      String fingerprint = exitListEntry.getFingerprint();
-      if (exitListEntry.getScanMillis() <
-          this.now - DateTimeHelper.ONE_DAY) {
-        continue;
-      }
-      if (!this.exitListEntries.containsKey(fingerprint)) {
-        this.exitListEntries.put(fingerprint,
-            new HashMap<String, Long>());
-      }
-      String exitAddress = exitListEntry.getExitAddress();
-      long scanMillis = exitListEntry.getScanMillis();
-      if (!this.exitListEntries.get(fingerprint).containsKey(exitAddress)
-          || this.exitListEntries.get(fingerprint).get(exitAddress)
-          < scanMillis) {
-        this.exitListEntries.get(fingerprint).put(exitAddress,
-            scanMillis);
-      }
-    }
-  }
-
-  private void processRelayNetworkStatusConsensus(
-      RelayNetworkStatusConsensus consensus) {
-    long validAfterMillis = consensus.getValidAfterMillis();
-    if (validAfterMillis > this.relaysLastValidAfterMillis) {
-      this.relaysLastValidAfterMillis = validAfterMillis;
-    }
-    Set<String> recommendedVersions = null;
-    if (consensus.getRecommendedServerVersions() != null) {
-      recommendedVersions = new HashSet<String>();
-      for (String recommendedVersion :
-          consensus.getRecommendedServerVersions()) {
-        recommendedVersions.add("Tor " + recommendedVersion);
-      }
-    }
-    for (NetworkStatusEntry entry :
-        consensus.getStatusEntries().values()) {
-      String nickname = entry.getNickname();
-      String fingerprint = entry.getFingerprint();
-      String address = entry.getAddress();
-      SortedSet<String> orAddressesAndPorts = new TreeSet<String>(
-          entry.getOrAddresses());
-      int orPort = entry.getOrPort();
-      int dirPort = entry.getDirPort();
-      SortedSet<String> relayFlags = entry.getFlags();
-      long consensusWeight = entry.getBandwidth();
-      String defaultPolicy = entry.getDefaultPolicy();
-      String portList = entry.getPortList();
-      Boolean recommendedVersion = (recommendedVersions == null ||
-          entry.getVersion() == null) ? null :
-          recommendedVersions.contains(entry.getVersion());
-      NodeStatus newNodeStatus = new NodeStatus(true, nickname,
-          fingerprint, address, orAddressesAndPorts, null,
-          validAfterMillis, orPort, dirPort, relayFlags, consensusWeight,
-          null, null, -1L, defaultPolicy, portList, validAfterMillis,
-          validAfterMillis, null, null, recommendedVersion, null);
-      if (this.knownNodes.containsKey(fingerprint)) {
-        this.knownNodes.get(fingerprint).update(newNodeStatus);
-      } else {
-        this.knownNodes.put(fingerprint, newNodeStatus);
-      }
-    }
-    this.relayConsensusesProcessed++;
-    if (this.relaysLastValidAfterMillis == validAfterMillis) {
-      this.lastBandwidthWeights = consensus.getBandwidthWeights();
-    }
-  }
-
-  private void processBridgeServerDescriptor(
-      ServerDescriptor descriptor) {
-    String fingerprint = descriptor.getFingerprint();
-    DetailsStatus detailsStatus = this.documentStore.retrieve(
-        DetailsStatus.class, true, fingerprint);
-    String publishedDateTime =
-        DateTimeHelper.format(descriptor.getPublishedMillis());
-    if (detailsStatus == null) {
-      detailsStatus = new DetailsStatus();
-    } else if (detailsStatus.getDescPublished() != null &&
-        publishedDateTime.compareTo(
-            detailsStatus.getDescPublished()) < 0) {
-      return;
-    }
-    String lastRestartedString = DateTimeHelper.format(
-        descriptor.getPublishedMillis() - descriptor.getUptime()
-        * DateTimeHelper.ONE_SECOND);
-    int advertisedBandwidth = Math.min(descriptor.getBandwidthRate(),
-        Math.min(descriptor.getBandwidthBurst(),
-        descriptor.getBandwidthObserved()));
-    detailsStatus.setDescPublished(publishedDateTime);
-    detailsStatus.setLastRestarted(lastRestartedString);
-    detailsStatus.setAdvertisedBandwidth(advertisedBandwidth);
-    detailsStatus.setPlatform(descriptor.getPlatform());
-    this.documentStore.store(detailsStatus, fingerprint);
-  }
-
-  private void processBridgePoolAssignment(
-      BridgePoolAssignment bridgePoolAssignment) {
-    for (Map.Entry<String, String> e :
-        bridgePoolAssignment.getEntries().entrySet()) {
-      String fingerprint = e.getKey();
-      String details = e.getValue();
-      DetailsStatus detailsStatus = this.documentStore.retrieve(
-          DetailsStatus.class, true, fingerprint);
-      if (detailsStatus == null) {
-        detailsStatus = new DetailsStatus();
-      } else if (details.equals(detailsStatus.getPoolAssignment())) {
-        continue;
-      }
-      detailsStatus.setPoolAssignment(details);
-      this.documentStore.store(detailsStatus, fingerprint);
-    }
-  }
-
-  private void processBridgeNetworkStatus(BridgeNetworkStatus status) {
-    long publishedMillis = status.getPublishedMillis();
-    if (publishedMillis > this.bridgesLastPublishedMillis) {
-      this.bridgesLastPublishedMillis = publishedMillis;
-    }
-    for (NetworkStatusEntry entry : status.getStatusEntries().values()) {
-      String nickname = entry.getNickname();
-      String fingerprint = entry.getFingerprint();
-      String address = entry.getAddress();
-      SortedSet<String> orAddressesAndPorts = new TreeSet<String>(
-          entry.getOrAddresses());
-      int orPort = entry.getOrPort();
-      int dirPort = entry.getDirPort();
-      SortedSet<String> relayFlags = entry.getFlags();
-      NodeStatus newNodeStatus = new NodeStatus(false, nickname,
-          fingerprint, address, orAddressesAndPorts, null,
-          publishedMillis, orPort, dirPort, relayFlags, -1L, "??", null,
-          -1L, null, null, publishedMillis, -1L, null, null, null, null);
-      if (this.knownNodes.containsKey(fingerprint)) {
-        this.knownNodes.get(fingerprint).update(newNodeStatus);
-      } else {
-        this.knownNodes.put(fingerprint, newNodeStatus);
-      }
-    }
-    this.bridgeStatusesProcessed++;
-  }
-
-  public void updateStatuses() {
-    this.readStatusSummary();
-    Logger.printStatusTime("Read status summary");
-    this.setCurrentNodes();
-    Logger.printStatusTime("Set current node fingerprints");
-    this.startReverseDomainNameLookups();
-    Logger.printStatusTime("Started reverse domain name lookups");
-    this.lookUpCitiesAndASes();
-    Logger.printStatusTime("Looked up cities and ASes");
-    this.setDescriptorPartsOfNodeStatus();
-    Logger.printStatusTime("Set descriptor parts of node statuses.");
-    this.calculatePathSelectionProbabilities();
-    Logger.printStatusTime("Calculated path selection probabilities");
-    this.finishReverseDomainNameLookups();
-    Logger.printStatusTime("Finished reverse domain name lookups");
-    this.writeStatusSummary();
-    Logger.printStatusTime("Wrote status summary");
-    this.updateDetailsStatuses();
-    Logger.printStatusTime("Updated exit addresses in details statuses");
-  }
-
-  private void readStatusSummary() {
-    SortedSet<String> fingerprints = this.documentStore.list(
-        NodeStatus.class);
-    for (String fingerprint : fingerprints) {
-      NodeStatus node = this.documentStore.retrieve(NodeStatus.class,
-          true, fingerprint);
-      if (node.isRelay()) {
-        this.relaysLastValidAfterMillis = Math.max(
-            this.relaysLastValidAfterMillis, node.getLastSeenMillis());
-      } else {
-        this.bridgesLastPublishedMillis = Math.max(
-            this.bridgesLastPublishedMillis, node.getLastSeenMillis());
-      }
-      if (this.knownNodes.containsKey(fingerprint)) {
-        this.knownNodes.get(fingerprint).update(node);
-      } else {
-        this.knownNodes.put(fingerprint, node);
-      }
-    }
-  }
-
-  private void setCurrentNodes() {
-    long cutoff = Math.max(this.relaysLastValidAfterMillis,
-        this.bridgesLastPublishedMillis) - 7L * 24L * 60L * 60L * 1000L;
-    SortedMap<String, NodeStatus> currentNodes =
-        new TreeMap<String, NodeStatus>();
-    for (Map.Entry<String, NodeStatus> e : this.knownNodes.entrySet()) {
-      if (e.getValue().getLastSeenMillis() >= cutoff) {
-        currentNodes.put(e.getKey(), e.getValue());
-      }
-    }
-    this.relays = new TreeMap<String, NodeStatus>();
-    this.bridges = new TreeMap<String, NodeStatus>();
-    for (Map.Entry<String, NodeStatus> e : currentNodes.entrySet()) {
-      if (e.getValue().isRelay()) {
-        this.relays.put(e.getKey(), e.getValue());
-      } else {
-        this.bridges.put(e.getKey(), e.getValue());
-      }
-    }
-  }
-
-  private void startReverseDomainNameLookups() {
-    Map<String, Long> addressLastLookupTimes =
-        new HashMap<String, Long>();
-    for (NodeStatus relay : relays.values()) {
-      addressLastLookupTimes.put(relay.getAddress(),
-          relay.getLastRdnsLookup());
-    }
-    this.reverseDomainNameResolver.setAddresses(addressLastLookupTimes);
-    this.reverseDomainNameResolver.startReverseDomainNameLookups();
-  }
-
-  private void lookUpCitiesAndASes() {
-    SortedSet<String> addressStrings = new TreeSet<String>();
-    for (NodeStatus node : this.knownNodes.values()) {
-      if (node.isRelay()) {
-        addressStrings.add(node.getAddress());
-      }
-    }
-    if (addressStrings.isEmpty()) {
-      System.err.println("No relay IP addresses to resolve to cities or "
-          + "ASN.");
-      return;
-    }
-    SortedMap<String, LookupResult> lookupResults =
-        this.lookupService.lookup(addressStrings);
-    for (NodeStatus node : knownNodes.values()) {
-      if (!node.isRelay()) {
-        continue;
-      }
-      String addressString = node.getAddress();
-      if (lookupResults.containsKey(addressString)) {
-        LookupResult lookupResult = lookupResults.get(addressString);
-        node.setCountryCode(lookupResult.getCountryCode());
-        node.setCountryName(lookupResult.getCountryName());
-        node.setRegionName(lookupResult.getRegionName());
-        node.setCityName(lookupResult.getCityName());
-        node.setLatitude(lookupResult.getLatitude());
-        node.setLongitude(lookupResult.getLongitude());
-        node.setASNumber(lookupResult.getAsNumber());
-        node.setASName(lookupResult.getAsName());
-      }
-    }
-  }
-
-  private void setDescriptorPartsOfNodeStatus() {
-    for (Map.Entry<String, NodeStatus> e : this.knownNodes.entrySet()) {
-      String fingerprint = e.getKey();
-      NodeStatus node = e.getValue();
-      if (node.isRelay()) {
-        if (node.getRelayFlags().contains("Running") &&
-            node.getLastSeenMillis() == this.relaysLastValidAfterMillis) {
-          node.setRunning(true);
-        }
-        DetailsStatus detailsStatus = this.documentStore.retrieve(
-            DetailsStatus.class, true, fingerprint);
-        if (detailsStatus != null) {
-          node.setContact(detailsStatus.getContact());
-          if (detailsStatus.getExitAddresses() != null) {
-            for (Map.Entry<String, Long> ea :
-                detailsStatus.getExitAddresses().entrySet()) {
-              if (ea.getValue() >= this.now - DateTimeHelper.ONE_DAY) {
-                node.addExitAddress(ea.getKey());
-              }
-            }
-          }
-          if (detailsStatus.getFamily() != null &&
-              !detailsStatus.getFamily().isEmpty()) {
-            SortedSet<String> familyFingerprints = new TreeSet<String>();
-            for (String familyMember : detailsStatus.getFamily()) {
-              if (familyMember.startsWith("$") &&
-                  familyMember.length() == 41) {
-                familyFingerprints.add(familyMember.substring(1));
-              }
-            }
-            if (!familyFingerprints.isEmpty()) {
-              node.setFamilyFingerprints(familyFingerprints);
-            }
-          }
-        }
-      }
-      if (!node.isRelay() && node.getRelayFlags().contains("Running") &&
-          node.getLastSeenMillis() == this.bridgesLastPublishedMillis) {
-        node.setRunning(true);
-      }
-    }
-  }
-
-  private void calculatePathSelectionProbabilities() {
-    boolean consensusContainsBandwidthWeights = false;
-    double wgg = 0.0, wgd = 0.0, wmg = 0.0, wmm = 0.0, wme = 0.0,
-        wmd = 0.0, wee = 0.0, wed = 0.0;
-    if (this.lastBandwidthWeights != null) {
-      SortedSet<String> weightKeys = new TreeSet<String>(Arrays.asList(
-          "Wgg,Wgd,Wmg,Wmm,Wme,Wmd,Wee,Wed".split(",")));
-      weightKeys.removeAll(this.lastBandwidthWeights.keySet());
-      if (weightKeys.isEmpty()) {
-        consensusContainsBandwidthWeights = true;
-        wgg = ((double) this.lastBandwidthWeights.get("Wgg")) / 10000.0;
-        wgd = ((double) this.lastBandwidthWeights.get("Wgd")) / 10000.0;
-        wmg = ((double) this.lastBandwidthWeights.get("Wmg")) / 10000.0;
-        wmm = ((double) this.lastBandwidthWeights.get("Wmm")) / 10000.0;
-        wme = ((double) this.lastBandwidthWeights.get("Wme")) / 10000.0;
-        wmd = ((double) this.lastBandwidthWeights.get("Wmd")) / 10000.0;
-        wee = ((double) this.lastBandwidthWeights.get("Wee")) / 10000.0;
-        wed = ((double) this.lastBandwidthWeights.get("Wed")) / 10000.0;
-      }
-    } else {
-      System.err.println("Could not determine most recent Wxx parameter "
-          + "values, probably because we didn't parse a consensus in "
-          + "this execution.  All relays' guard/middle/exit weights are "
-          + "going to be 0.0.");
-    }
-    SortedMap<String, Double>
-        advertisedBandwidths = new TreeMap<String, Double>(),
-        consensusWeights = new TreeMap<String, Double>(),
-        guardWeights = new TreeMap<String, Double>(),
-        middleWeights = new TreeMap<String, Double>(),
-        exitWeights = new TreeMap<String, Double>();
-    double totalAdvertisedBandwidth = 0.0;
-    double totalConsensusWeight = 0.0;
-    double totalGuardWeight = 0.0;
-    double totalMiddleWeight = 0.0;
-    double totalExitWeight = 0.0;
-    for (Map.Entry<String, NodeStatus> e : this.relays.entrySet()) {
-      String fingerprint = e.getKey();
-      NodeStatus relay = e.getValue();
-      if (!relay.getRunning()) {
-        continue;
-      }
-      boolean isExit = relay.getRelayFlags().contains("Exit") &&
-          !relay.getRelayFlags().contains("BadExit");
-      boolean isGuard = relay.getRelayFlags().contains("Guard");
-      DetailsStatus detailsStatus = this.documentStore.retrieve(
-          DetailsStatus.class, true, fingerprint);
-      if (detailsStatus != null) {
-        double advertisedBandwidth =
-            detailsStatus.getAdvertisedBandwidth();
-        if (advertisedBandwidth >= 0.0) {
-          advertisedBandwidths.put(fingerprint, advertisedBandwidth);
-          totalAdvertisedBandwidth += advertisedBandwidth;
-        }
-      }
-      double consensusWeight = (double) relay.getConsensusWeight();
-      consensusWeights.put(fingerprint, consensusWeight);
-      totalConsensusWeight += consensusWeight;
-      if (consensusContainsBandwidthWeights) {
-        double guardWeight = consensusWeight,
-            middleWeight = consensusWeight,
-            exitWeight = consensusWeight;
-        if (isGuard && isExit) {
-          guardWeight *= wgd;
-          middleWeight *= wmd;
-          exitWeight *= wed;
-        } else if (isGuard) {
-          guardWeight *= wgg;
-          middleWeight *= wmg;
-          exitWeight = 0.0;
-        } else if (isExit) {
-          guardWeight = 0.0;
-          middleWeight *= wme;
-          exitWeight *= wee;
-        } else {
-          guardWeight = 0.0;
-          middleWeight *= wmm;
-          exitWeight = 0.0;
-        }
-        guardWeights.put(fingerprint, guardWeight);
-        middleWeights.put(fingerprint, middleWeight);
-        exitWeights.put(fingerprint, exitWeight);
-        totalGuardWeight += guardWeight;
-        totalMiddleWeight += middleWeight;
-        totalExitWeight += exitWeight;
-      }
-    }
-    for (Map.Entry<String, NodeStatus> e : this.relays.entrySet()) {
-      String fingerprint = e.getKey();
-      NodeStatus relay = e.getValue();
-      if (advertisedBandwidths.containsKey(fingerprint)) {
-        relay.setAdvertisedBandwidthFraction(advertisedBandwidths.get(
-            fingerprint) / totalAdvertisedBandwidth);
-      }
-      if (consensusWeights.containsKey(fingerprint)) {
-        relay.setConsensusWeightFraction(consensusWeights.get(fingerprint)
-            / totalConsensusWeight);
-      }
-      if (guardWeights.containsKey(fingerprint)) {
-        relay.setGuardProbability(guardWeights.get(fingerprint)
-            / totalGuardWeight);
-      }
-      if (middleWeights.containsKey(fingerprint)) {
-        relay.setMiddleProbability(middleWeights.get(fingerprint)
-            / totalMiddleWeight);
-      }
-      if (exitWeights.containsKey(fingerprint)) {
-        relay.setExitProbability(exitWeights.get(fingerprint)
-            / totalExitWeight);
-      }
-    }
-  }
-
-  private void finishReverseDomainNameLookups() {
-    this.reverseDomainNameResolver.finishReverseDomainNameLookups();
-    Map<String, String> lookupResults =
-        this.reverseDomainNameResolver.getLookupResults();
-    long startedRdnsLookups =
-        this.reverseDomainNameResolver.getLookupStartMillis();
-    for (NodeStatus relay : relays.values()) {
-      if (lookupResults.containsKey(relay.getAddress())) {
-        relay.setHostName(lookupResults.get(relay.getAddress()));
-        relay.setLastRdnsLookup(startedRdnsLookups);
-      }
-    }
-  }
-
-  private void writeStatusSummary() {
-    for (Map.Entry<String, NodeStatus> e : this.knownNodes.entrySet()) {
-      this.documentStore.store(e.getValue(), e.getKey());
-    }
-  }
-
-  private void updateDetailsStatuses() {
-    SortedSet<String> fingerprints = new TreeSet<String>();
-    fingerprints.addAll(this.exitListEntries.keySet());
-    for (String fingerprint : fingerprints) {
-      DetailsStatus detailsStatus = this.documentStore.retrieve(
-          DetailsStatus.class, true, fingerprint);
-      if (detailsStatus == null) {
-        detailsStatus = new DetailsStatus();
-      }
-      Map<String, Long> exitAddresses = new HashMap<String, Long>();
-      if (detailsStatus.getExitAddresses() != null) {
-        for (Map.Entry<String, Long> e :
-            detailsStatus.getExitAddresses().entrySet()) {
-          if (e.getValue() >= this.now - DateTimeHelper.ONE_DAY) {
-            exitAddresses.put(e.getKey(), e.getValue());
-          }
-        }
-      }
-      if (this.exitListEntries.containsKey(fingerprint)) {
-        for (Map.Entry<String, Long> e :
-            this.exitListEntries.get(fingerprint).entrySet()) {
-          if (!exitAddresses.containsKey(e.getKey()) ||
-              exitAddresses.get(e.getKey()) < e.getValue()) {
-            exitAddresses.put(e.getKey(), e.getValue());
-          }
-        }
-      }
-      if (this.knownNodes.containsKey(fingerprint)) {
-        for (String orAddress :
-            this.knownNodes.get(fingerprint).getOrAddresses()) {
-          this.exitListEntries.remove(orAddress);
-        }
-      }
-      detailsStatus.setExitAddresses(exitAddresses);
-      this.documentStore.store(detailsStatus, fingerprint);
-    }
-  }
-
-  public String getStatsString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append("    " + Logger.formatDecimalNumber(
-        relayConsensusesProcessed) + " relay consensuses processed\n");
-    sb.append("    " + Logger.formatDecimalNumber(bridgeStatusesProcessed)
-        + " bridge statuses processed\n");
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/NodeIndexer.java b/src/org/torproject/onionoo/NodeIndexer.java
deleted file mode 100644
index d24284e..0000000
--- a/src/org/torproject/onionoo/NodeIndexer.java
+++ /dev/null
@@ -1,425 +0,0 @@
-package org.torproject.onionoo;
-
-import java.io.File;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-
-import javax.servlet.ServletContext;
-import javax.servlet.ServletContextEvent;
-import javax.servlet.ServletContextListener;
-
-class NodeIndex {
-
-  private String relaysPublishedString;
-  public void setRelaysPublishedString(String relaysPublishedString) {
-    this.relaysPublishedString = relaysPublishedString;
-  }
-  public String getRelaysPublishedString() {
-    return relaysPublishedString;
-  }
-
-  private String bridgesPublishedString;
-  public void setBridgesPublishedString(String bridgesPublishedString) {
-    this.bridgesPublishedString = bridgesPublishedString;
-  }
-  public String getBridgesPublishedString() {
-    return bridgesPublishedString;
-  }
-
-  private List<String> relaysByConsensusWeight;
-  public void setRelaysByConsensusWeight(
-      List<String> relaysByConsensusWeight) {
-    this.relaysByConsensusWeight = relaysByConsensusWeight;
-  }
-  public List<String> getRelaysByConsensusWeight() {
-    return relaysByConsensusWeight;
-  }
-
-
-  private Map<String, SummaryDocument> relayFingerprintSummaryLines;
-  public void setRelayFingerprintSummaryLines(
-      Map<String, SummaryDocument> relayFingerprintSummaryLines) {
-    this.relayFingerprintSummaryLines = relayFingerprintSummaryLines;
-  }
-  public Map<String, SummaryDocument> getRelayFingerprintSummaryLines() {
-    return this.relayFingerprintSummaryLines;
-  }
-
-  private Map<String, SummaryDocument> bridgeFingerprintSummaryLines;
-  public void setBridgeFingerprintSummaryLines(
-      Map<String, SummaryDocument> bridgeFingerprintSummaryLines) {
-    this.bridgeFingerprintSummaryLines = bridgeFingerprintSummaryLines;
-  }
-  public Map<String, SummaryDocument> getBridgeFingerprintSummaryLines() {
-    return this.bridgeFingerprintSummaryLines;
-  }
-
-  private Map<String, Set<String>> relaysByCountryCode = null;
-  public void setRelaysByCountryCode(
-      Map<String, Set<String>> relaysByCountryCode) {
-    this.relaysByCountryCode = relaysByCountryCode;
-  }
-  public Map<String, Set<String>> getRelaysByCountryCode() {
-    return relaysByCountryCode;
-  }
-
-  private Map<String, Set<String>> relaysByASNumber = null;
-  public void setRelaysByASNumber(
-      Map<String, Set<String>> relaysByASNumber) {
-    this.relaysByASNumber = relaysByASNumber;
-  }
-  public Map<String, Set<String>> getRelaysByASNumber() {
-    return relaysByASNumber;
-  }
-
-  private Map<String, Set<String>> relaysByFlag = null;
-  public void setRelaysByFlag(Map<String, Set<String>> relaysByFlag) {
-    this.relaysByFlag = relaysByFlag;
-  }
-  public Map<String, Set<String>> getRelaysByFlag() {
-    return relaysByFlag;
-  }
-
-  private Map<String, Set<String>> bridgesByFlag = null;
-  public void setBridgesByFlag(Map<String, Set<String>> bridgesByFlag) {
-    this.bridgesByFlag = bridgesByFlag;
-  }
-  public Map<String, Set<String>> getBridgesByFlag() {
-    return bridgesByFlag;
-  }
-
-  private Map<String, Set<String>> relaysByContact = null;
-  public void setRelaysByContact(
-      Map<String, Set<String>> relaysByContact) {
-    this.relaysByContact = relaysByContact;
-  }
-  public Map<String, Set<String>> getRelaysByContact() {
-    return relaysByContact;
-  }
-
-  private Map<String, Set<String>> relaysByFamily = null;
-  public void setRelaysByFamily(Map<String, Set<String>> relaysByFamily) {
-    this.relaysByFamily = relaysByFamily;
-  }
-  public Map<String, Set<String>> getRelaysByFamily() {
-    return this.relaysByFamily;
-  }
-
-  private SortedMap<Integer, Set<String>> relaysByFirstSeenDays;
-  public void setRelaysByFirstSeenDays(
-      SortedMap<Integer, Set<String>> relaysByFirstSeenDays) {
-    this.relaysByFirstSeenDays = relaysByFirstSeenDays;
-  }
-  public SortedMap<Integer, Set<String>> getRelaysByFirstSeenDays() {
-    return relaysByFirstSeenDays;
-  }
-
-  private SortedMap<Integer, Set<String>> bridgesByFirstSeenDays;
-  public void setBridgesByFirstSeenDays(
-      SortedMap<Integer, Set<String>> bridgesByFirstSeenDays) {
-    this.bridgesByFirstSeenDays = bridgesByFirstSeenDays;
-  }
-  public SortedMap<Integer, Set<String>> getBridgesByFirstSeenDays() {
-    return bridgesByFirstSeenDays;
-  }
-
-  private SortedMap<Integer, Set<String>> relaysByLastSeenDays;
-  public void setRelaysByLastSeenDays(
-      SortedMap<Integer, Set<String>> relaysByLastSeenDays) {
-    this.relaysByLastSeenDays = relaysByLastSeenDays;
-  }
-  public SortedMap<Integer, Set<String>> getRelaysByLastSeenDays() {
-    return relaysByLastSeenDays;
-  }
-
-  private SortedMap<Integer, Set<String>> bridgesByLastSeenDays;
-  public void setBridgesByLastSeenDays(
-      SortedMap<Integer, Set<String>> bridgesByLastSeenDays) {
-    this.bridgesByLastSeenDays = bridgesByLastSeenDays;
-  }
-  public SortedMap<Integer, Set<String>> getBridgesByLastSeenDays() {
-    return bridgesByLastSeenDays;
-  }
-}
-
-public class NodeIndexer implements ServletContextListener, Runnable {
-
-  public void contextInitialized(ServletContextEvent contextEvent) {
-    ServletContext servletContext = contextEvent.getServletContext();
-    File outDir = new File(servletContext.getInitParameter("outDir"));
-    DocumentStore documentStore = ApplicationFactory.getDocumentStore();
-    documentStore.setOutDir(outDir);
-    /* The servlet container created us, and we need to avoid that
-     * ApplicationFactory creates another instance of us. */
-    ApplicationFactory.setNodeIndexer(this);
-    this.startIndexing();
-  }
-
-  public void contextDestroyed(ServletContextEvent contextEvent) {
-    this.stopIndexing();
-  }
-
-  private long lastIndexed = -1L;
-
-  private NodeIndex latestNodeIndex = null;
-
-  private Thread nodeIndexerThread = null;
-
-  public synchronized long getLastIndexed(long timeoutMillis) {
-    if (this.lastIndexed == -1L && this.nodeIndexerThread != null &&
-        timeoutMillis > 0L) {
-      try {
-        this.wait(timeoutMillis);
-      } catch (InterruptedException e) {
-      }
-    }
-    return this.lastIndexed;
-  }
-
-  public synchronized NodeIndex getLatestNodeIndex(long timeoutMillis) {
-    if (this.latestNodeIndex == null && this.nodeIndexerThread != null &&
-        timeoutMillis > 0L) {
-      try {
-        this.wait(timeoutMillis);
-      } catch (InterruptedException e) {
-      }
-    }
-    return this.latestNodeIndex;
-  }
-
-  public synchronized void startIndexing() {
-    if (this.nodeIndexerThread == null) {
-      this.nodeIndexerThread = new Thread(this);
-      this.nodeIndexerThread.setDaemon(true);
-      this.nodeIndexerThread.start();
-    }
-  }
-
-  public void run() {
-    while (this.nodeIndexerThread != null) {
-      this.indexNodeStatuses();
-      try {
-        Thread.sleep(DateTimeHelper.ONE_MINUTE);
-      } catch (InterruptedException e) {
-      }
-    }
-  }
-
-  public synchronized void stopIndexing() {
-    Thread indexerThread = this.nodeIndexerThread;
-    this.nodeIndexerThread = null;
-    indexerThread.interrupt();
-  }
-
-  private void indexNodeStatuses() {
-    long updateStatusMillis = -1L;
-    DocumentStore documentStore = ApplicationFactory.getDocumentStore();
-    UpdateStatus updateStatus = documentStore.retrieve(UpdateStatus.class,
-        false);
-    if (updateStatus != null &&
-        updateStatus.getDocumentString() != null) {
-      String updateString = updateStatus.getDocumentString();
-      try {
-        updateStatusMillis = Long.parseLong(updateString.trim());
-      } catch (NumberFormatException e) {
-        /* Handle below. */
-      }
-    }
-    synchronized (this) {
-      if (updateStatusMillis <= this.lastIndexed) {
-        return;
-      }
-    }
-    List<String> newRelaysByConsensusWeight = new ArrayList<String>();
-    Map<String, SummaryDocument>
-        newRelayFingerprintSummaryLines =
-        new HashMap<String, SummaryDocument>(),
-        newBridgeFingerprintSummaryLines =
-        new HashMap<String, SummaryDocument>();
-    Map<String, Set<String>>
-        newRelaysByCountryCode = new HashMap<String, Set<String>>(),
-        newRelaysByASNumber = new HashMap<String, Set<String>>(),
-        newRelaysByFlag = new HashMap<String, Set<String>>(),
-        newBridgesByFlag = new HashMap<String, Set<String>>(),
-        newRelaysByContact = new HashMap<String, Set<String>>(),
-        newRelaysByFamily = new HashMap<String, Set<String>>();
-    SortedMap<Integer, Set<String>>
-        newRelaysByFirstSeenDays = new TreeMap<Integer, Set<String>>(),
-        newBridgesByFirstSeenDays = new TreeMap<Integer, Set<String>>(),
-        newRelaysByLastSeenDays = new TreeMap<Integer, Set<String>>(),
-        newBridgesByLastSeenDays = new TreeMap<Integer, Set<String>>();
-    Set<SummaryDocument> currentRelays = new HashSet<SummaryDocument>(),
-        currentBridges = new HashSet<SummaryDocument>();
-    SortedSet<String> fingerprints = documentStore.list(
-        SummaryDocument.class);
-    long relaysLastValidAfterMillis = 0L, bridgesLastPublishedMillis = 0L;
-    for (String fingerprint : fingerprints) {
-      SummaryDocument node = documentStore.retrieve(SummaryDocument.class,
-          true, fingerprint);
-      if (node.isRelay()) {
-        relaysLastValidAfterMillis = Math.max(
-            relaysLastValidAfterMillis, node.getLastSeenMillis());
-        currentRelays.add(node);
-      } else {
-        bridgesLastPublishedMillis = Math.max(
-            bridgesLastPublishedMillis, node.getLastSeenMillis());
-        currentBridges.add(node);
-      }
-    }
-    Time time = ApplicationFactory.getTime();
-    List<String> orderRelaysByConsensusWeight = new ArrayList<String>();
-    for (SummaryDocument entry : currentRelays) {
-      String fingerprint = entry.getFingerprint().toUpperCase();
-      String hashedFingerprint = entry.getHashedFingerprint().
-          toUpperCase();
-      newRelayFingerprintSummaryLines.put(fingerprint, entry);
-      newRelayFingerprintSummaryLines.put(hashedFingerprint, entry);
-      long consensusWeight = entry.getConsensusWeight();
-      orderRelaysByConsensusWeight.add(String.format("%020d %s",
-          consensusWeight, fingerprint));
-      orderRelaysByConsensusWeight.add(String.format("%020d %s",
-          consensusWeight, hashedFingerprint));
-      if (entry.getCountryCode() != null) {
-        String countryCode = entry.getCountryCode();
-        if (!newRelaysByCountryCode.containsKey(countryCode)) {
-          newRelaysByCountryCode.put(countryCode,
-              new HashSet<String>());
-        }
-        newRelaysByCountryCode.get(countryCode).add(fingerprint);
-        newRelaysByCountryCode.get(countryCode).add(hashedFingerprint);
-      }
-      if (entry.getASNumber() != null) {
-        String aSNumber = entry.getASNumber();
-        if (!newRelaysByASNumber.containsKey(aSNumber)) {
-          newRelaysByASNumber.put(aSNumber, new HashSet<String>());
-        }
-        newRelaysByASNumber.get(aSNumber).add(fingerprint);
-        newRelaysByASNumber.get(aSNumber).add(hashedFingerprint);
-      }
-      for (String flag : entry.getRelayFlags()) {
-        String flagLowerCase = flag.toLowerCase();
-        if (!newRelaysByFlag.containsKey(flagLowerCase)) {
-          newRelaysByFlag.put(flagLowerCase, new HashSet<String>());
-        }
-        newRelaysByFlag.get(flagLowerCase).add(fingerprint);
-        newRelaysByFlag.get(flagLowerCase).add(hashedFingerprint);
-      }
-      if (entry.getFamilyFingerprints() != null) {
-        newRelaysByFamily.put(fingerprint, entry.getFamilyFingerprints());
-      }
-      int daysSinceFirstSeen = (int) ((time.currentTimeMillis()
-          - entry.getFirstSeenMillis()) / DateTimeHelper.ONE_DAY);
-      if (!newRelaysByFirstSeenDays.containsKey(daysSinceFirstSeen)) {
-        newRelaysByFirstSeenDays.put(daysSinceFirstSeen,
-            new HashSet<String>());
-      }
-      newRelaysByFirstSeenDays.get(daysSinceFirstSeen).add(fingerprint);
-      newRelaysByFirstSeenDays.get(daysSinceFirstSeen).add(
-          hashedFingerprint);
-      int daysSinceLastSeen = (int) ((time.currentTimeMillis()
-          - entry.getLastSeenMillis()) / DateTimeHelper.ONE_DAY);
-      if (!newRelaysByLastSeenDays.containsKey(daysSinceLastSeen)) {
-        newRelaysByLastSeenDays.put(daysSinceLastSeen,
-            new HashSet<String>());
-      }
-      newRelaysByLastSeenDays.get(daysSinceLastSeen).add(fingerprint);
-      newRelaysByLastSeenDays.get(daysSinceLastSeen).add(
-          hashedFingerprint);
-      String contact = entry.getContact();
-      if (!newRelaysByContact.containsKey(contact)) {
-        newRelaysByContact.put(contact, new HashSet<String>());
-      }
-      newRelaysByContact.get(contact).add(fingerprint);
-      newRelaysByContact.get(contact).add(hashedFingerprint);
-    }
-    Collections.sort(orderRelaysByConsensusWeight);
-    newRelaysByConsensusWeight = new ArrayList<String>();
-    for (String relay : orderRelaysByConsensusWeight) {
-      newRelaysByConsensusWeight.add(relay.split(" ")[1]);
-    }
-    for (Map.Entry<String, Set<String>> e :
-        newRelaysByFamily.entrySet()) {
-      String fingerprint = e.getKey();
-      Set<String> inMutualFamilyRelation = new HashSet<String>();
-      for (String otherFingerprint : e.getValue()) {
-        if (newRelaysByFamily.containsKey(otherFingerprint) &&
-            newRelaysByFamily.get(otherFingerprint).contains(
-                fingerprint)) {
-          inMutualFamilyRelation.add(otherFingerprint);
-        }
-      }
-      e.getValue().retainAll(inMutualFamilyRelation);
-    }
-    for (SummaryDocument entry : currentBridges) {
-      String hashedFingerprint = entry.getFingerprint().toUpperCase();
-      String hashedHashedFingerprint = entry.getHashedFingerprint().
-          toUpperCase();
-      newBridgeFingerprintSummaryLines.put(hashedFingerprint, entry);
-      newBridgeFingerprintSummaryLines.put(hashedHashedFingerprint,
-          entry);
-      for (String flag : entry.getRelayFlags()) {
-        String flagLowerCase = flag.toLowerCase();
-        if (!newBridgesByFlag.containsKey(flagLowerCase)) {
-          newBridgesByFlag.put(flagLowerCase, new HashSet<String>());
-        }
-        newBridgesByFlag.get(flagLowerCase).add(hashedFingerprint);
-        newBridgesByFlag.get(flagLowerCase).add(
-            hashedHashedFingerprint);
-      }
-      int daysSinceFirstSeen = (int) ((time.currentTimeMillis()
-          - entry.getFirstSeenMillis()) / DateTimeHelper.ONE_DAY);
-      if (!newBridgesByFirstSeenDays.containsKey(daysSinceFirstSeen)) {
-        newBridgesByFirstSeenDays.put(daysSinceFirstSeen,
-            new HashSet<String>());
-      }
-      newBridgesByFirstSeenDays.get(daysSinceFirstSeen).add(
-          hashedFingerprint);
-      newBridgesByFirstSeenDays.get(daysSinceFirstSeen).add(
-          hashedHashedFingerprint);
-      int daysSinceLastSeen = (int) ((time.currentTimeMillis()
-          - entry.getLastSeenMillis()) / DateTimeHelper.ONE_DAY);
-      if (!newBridgesByLastSeenDays.containsKey(daysSinceLastSeen)) {
-        newBridgesByLastSeenDays.put(daysSinceLastSeen,
-            new HashSet<String>());
-      }
-      newBridgesByLastSeenDays.get(daysSinceLastSeen).add(
-          hashedFingerprint);
-      newBridgesByLastSeenDays.get(daysSinceLastSeen).add(
-          hashedHashedFingerprint);
-    }
-    NodeIndex newNodeIndex = new NodeIndex();
-    newNodeIndex.setRelaysByConsensusWeight(newRelaysByConsensusWeight);
-    newNodeIndex.setRelayFingerprintSummaryLines(
-        newRelayFingerprintSummaryLines);
-    newNodeIndex.setBridgeFingerprintSummaryLines(
-        newBridgeFingerprintSummaryLines);
-    newNodeIndex.setRelaysByCountryCode(newRelaysByCountryCode);
-    newNodeIndex.setRelaysByASNumber(newRelaysByASNumber);
-    newNodeIndex.setRelaysByFlag(newRelaysByFlag);
-    newNodeIndex.setBridgesByFlag(newBridgesByFlag);
-    newNodeIndex.setRelaysByContact(newRelaysByContact);
-    newNodeIndex.setRelaysByFamily(newRelaysByFamily);
-    newNodeIndex.setRelaysByFirstSeenDays(newRelaysByFirstSeenDays);
-    newNodeIndex.setRelaysByLastSeenDays(newRelaysByLastSeenDays);
-    newNodeIndex.setBridgesByFirstSeenDays(newBridgesByFirstSeenDays);
-    newNodeIndex.setBridgesByLastSeenDays(newBridgesByLastSeenDays);
-    newNodeIndex.setRelaysPublishedString(DateTimeHelper.format(
-        relaysLastValidAfterMillis));
-    newNodeIndex.setBridgesPublishedString(DateTimeHelper.format(
-        bridgesLastPublishedMillis));
-    synchronized (this) {
-      this.lastIndexed = updateStatusMillis;
-      this.latestNodeIndex = newNodeIndex;
-      this.notifyAll();
-    }
-  }
-}
-
diff --git a/src/org/torproject/onionoo/NodeStatus.java b/src/org/torproject/onionoo/NodeStatus.java
deleted file mode 100644
index 1bc74f9..0000000
--- a/src/org/torproject/onionoo/NodeStatus.java
+++ /dev/null
@@ -1,581 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-import org.apache.commons.codec.DecoderException;
-import org.apache.commons.codec.binary.Hex;
-import org.apache.commons.codec.digest.DigestUtils;
-
-/* Store search data of a single relay that was running in the past seven
- * days. */
-public class NodeStatus extends Document {
-
-  private boolean isRelay;
-  public boolean isRelay() {
-    return this.isRelay;
-  }
-
-  private String fingerprint;
-  public String getFingerprint() {
-    return this.fingerprint;
-  }
-
-  private String hashedFingerprint;
-  public String getHashedFingerprint() {
-    return this.hashedFingerprint;
-  }
-
-  private String nickname;
-  public String getNickname() {
-    return this.nickname;
-  }
-
-  private String address;
-  public String getAddress() {
-    return this.address;
-  }
-
-  private SortedSet<String> orAddresses;
-  private SortedSet<String> orAddressesAndPorts;
-  public SortedSet<String> getOrAddresses() {
-    return new TreeSet<String>(this.orAddresses);
-  }
-  public void addOrAddressAndPort(String orAddressAndPort) {
-    if (orAddressAndPort.contains(":") && orAddressAndPort.length() > 0) {
-      String orAddress = orAddressAndPort.substring(0,
-          orAddressAndPort.lastIndexOf(':'));
-      if (this.exitAddresses.contains(orAddress)) {
-        this.exitAddresses.remove(orAddress);
-      }
-      this.orAddresses.add(orAddress);
-      this.orAddressesAndPorts.add(orAddressAndPort);
-    }
-  }
-  public SortedSet<String> getOrAddressesAndPorts() {
-    return new TreeSet<String>(this.orAddressesAndPorts);
-  }
-
-  private SortedSet<String> exitAddresses;
-  public void addExitAddress(String exitAddress) {
-    if (exitAddress.length() > 0 && !this.address.equals(exitAddress) &&
-        !this.orAddresses.contains(exitAddress)) {
-      this.exitAddresses.add(exitAddress);
-    }
-  }
-  public SortedSet<String> getExitAddresses() {
-    return new TreeSet<String>(this.exitAddresses);
-  }
-
-  private Float latitude;
-  public void setLatitude(Float latitude) {
-    this.latitude = latitude;
-  }
-  public Float getLatitude() {
-    return this.latitude;
-  }
-
-  private Float longitude;
-  public void setLongitude(Float longitude) {
-    this.longitude = longitude;
-  }
-  public Float getLongitude() {
-    return this.longitude;
-  }
-
-  private String countryCode;
-  public void setCountryCode(String countryCode) {
-    this.countryCode = countryCode;
-  }
-  public String getCountryCode() {
-    return this.countryCode;
-  }
-
-  private String countryName;
-  public void setCountryName(String countryName) {
-    this.countryName = countryName;
-  }
-  public String getCountryName() {
-    return this.countryName;
-  }
-
-  private String regionName;
-  public void setRegionName(String regionName) {
-    this.regionName = regionName;
-  }
-  public String getRegionName() {
-    return this.regionName;
-  }
-
-  private String cityName;
-  public void setCityName(String cityName) {
-    this.cityName = cityName;
-  }
-  public String getCityName() {
-    return this.cityName;
-  }
-
-  private String aSName;
-  public void setASName(String aSName) {
-    this.aSName = aSName;
-  }
-  public String getASName() {
-    return this.aSName;
-  }
-
-  private String aSNumber;
-  public void setASNumber(String aSNumber) {
-    this.aSNumber = aSNumber;
-  }
-  public String getASNumber() {
-    return this.aSNumber;
-  }
-
-  private long firstSeenMillis;
-  public long getFirstSeenMillis() {
-    return this.firstSeenMillis;
-  }
-
-  private long lastSeenMillis;
-  public long getLastSeenMillis() {
-    return this.lastSeenMillis;
-  }
-
-  private int orPort;
-  public int getOrPort() {
-    return this.orPort;
-  }
-
-  private int dirPort;
-  public int getDirPort() {
-    return this.dirPort;
-  }
-
-  private SortedSet<String> relayFlags;
-  public SortedSet<String> getRelayFlags() {
-    return this.relayFlags;
-  }
-
-  private long consensusWeight;
-  public long getConsensusWeight() {
-    return this.consensusWeight;
-  }
-
-  private boolean running;
-  public void setRunning(boolean running) {
-    this.running = running;
-  }
-  public boolean getRunning() {
-    return this.running;
-  }
-
-  private String hostName;
-  public void setHostName(String hostName) {
-    this.hostName = hostName;
-  }
-  public String getHostName() {
-    return this.hostName;
-  }
-
-  private long lastRdnsLookup = -1L;
-  public void setLastRdnsLookup(long lastRdnsLookup) {
-    this.lastRdnsLookup = lastRdnsLookup;
-  }
-  public long getLastRdnsLookup() {
-    return this.lastRdnsLookup;
-  }
-
-  private double advertisedBandwidthFraction = -1.0;
-  public void setAdvertisedBandwidthFraction(
-      double advertisedBandwidthFraction) {
-    this.advertisedBandwidthFraction = advertisedBandwidthFraction;
-  }
-  public double getAdvertisedBandwidthFraction() {
-    return this.advertisedBandwidthFraction;
-  }
-
-  private double consensusWeightFraction = -1.0;
-  public void setConsensusWeightFraction(double consensusWeightFraction) {
-    this.consensusWeightFraction = consensusWeightFraction;
-  }
-  public double getConsensusWeightFraction() {
-    return this.consensusWeightFraction;
-  }
-
-  private double guardProbability = -1.0;
-  public void setGuardProbability(double guardProbability) {
-    this.guardProbability = guardProbability;
-  }
-  public double getGuardProbability() {
-    return this.guardProbability;
-  }
-
-  private double middleProbability = -1.0;
-  public void setMiddleProbability(double middleProbability) {
-    this.middleProbability = middleProbability;
-  }
-  public double getMiddleProbability() {
-    return this.middleProbability;
-  }
-
-  private double exitProbability = -1.0;
-  public void setExitProbability(double exitProbability) {
-    this.exitProbability = exitProbability;
-  }
-  public double getExitProbability() {
-    return this.exitProbability;
-  }
-
-  private String defaultPolicy;
-  public String getDefaultPolicy() {
-    return this.defaultPolicy;
-  }
-
-  private String portList;
-  public String getPortList() {
-    return this.portList;
-  }
-
-  private SortedMap<Long, Set<String>> lastAddresses;
-  public SortedMap<Long, Set<String>> getLastAddresses() {
-    return this.lastAddresses == null ? null :
-        new TreeMap<Long, Set<String>>(this.lastAddresses);
-  }
-  public long getLastChangedOrAddress() {
-    long lastChangedAddressesMillis = -1L;
-    if (this.lastAddresses != null) {
-      Set<String> lastAddresses = null;
-      for (Map.Entry<Long, Set<String>> e : this.lastAddresses.entrySet()) {
-        if (lastAddresses != null) {
-          for (String address : e.getValue()) {
-            if (!lastAddresses.contains(address)) {
-              return lastChangedAddressesMillis;
-            }
-          }
-        }
-        lastChangedAddressesMillis = e.getKey();
-        lastAddresses = e.getValue();
-      }
-    }
-    return lastChangedAddressesMillis;
-  }
-
-  private String contact;
-  public void setContact(String contact) {
-    if (contact == null) {
-      this.contact = null;
-    } else {
-      StringBuilder sb = new StringBuilder();
-      for (char c : contact.toLowerCase().toCharArray()) {
-        if (c >= 32 && c < 127) {
-          sb.append(c);
-        } else {
-          sb.append(" ");
-        }
-      }
-      this.contact = sb.toString();
-    }
-  }
-  public String getContact() {
-    return this.contact;
-  }
-
-  private Boolean recommendedVersion;
-  public Boolean getRecommendedVersion() {
-    return this.recommendedVersion;
-  }
-
-  private SortedSet<String> familyFingerprints;
-  public void setFamilyFingerprints(
-      SortedSet<String> familyFingerprints) {
-    this.familyFingerprints = familyFingerprints;
-  }
-  public SortedSet<String> getFamilyFingerprints() {
-    return this.familyFingerprints;
-  }
-
-  public NodeStatus(boolean isRelay, String nickname, String fingerprint,
-      String address, SortedSet<String> orAddressesAndPorts,
-      SortedSet<String> exitAddresses, long lastSeenMillis, int orPort,
-      int dirPort, SortedSet<String> relayFlags, long consensusWeight,
-      String countryCode, String hostName, long lastRdnsLookup,
-      String defaultPolicy, String portList, long firstSeenMillis,
-      long lastChangedAddresses, String aSNumber, String contact,
-      Boolean recommendedVersion, SortedSet<String> familyFingerprints) {
-    this.isRelay = isRelay;
-    this.nickname = nickname;
-    this.fingerprint = fingerprint;
-    try {
-      this.hashedFingerprint = DigestUtils.shaHex(Hex.decodeHex(
-          this.fingerprint.toCharArray())).toUpperCase();
-    } catch (DecoderException e) {
-      throw new IllegalArgumentException("Fingerprint '" + fingerprint
-          + "' is not a valid fingerprint.", e);
-    }
-    this.address = address;
-    this.exitAddresses = new TreeSet<String>();
-    if (exitAddresses != null) {
-      this.exitAddresses.addAll(exitAddresses);
-    }
-    this.exitAddresses.remove(this.address);
-    this.orAddresses = new TreeSet<String>();
-    this.orAddressesAndPorts = new TreeSet<String>();
-    if (orAddressesAndPorts != null) {
-      for (String orAddressAndPort : orAddressesAndPorts) {
-        this.addOrAddressAndPort(orAddressAndPort);
-      }
-    }
-    this.lastSeenMillis = lastSeenMillis;
-    this.orPort = orPort;
-    this.dirPort = dirPort;
-    this.relayFlags = relayFlags;
-    this.consensusWeight = consensusWeight;
-    this.countryCode = countryCode;
-    this.hostName = hostName;
-    this.lastRdnsLookup = lastRdnsLookup;
-    this.defaultPolicy = defaultPolicy;
-    this.portList = portList;
-    this.firstSeenMillis = firstSeenMillis;
-    this.lastAddresses =
-        new TreeMap<Long, Set<String>>(Collections.reverseOrder());
-    Set<String> addresses = new HashSet<String>();
-    addresses.add(address + ":" + orPort);
-    if (dirPort > 0) {
-      addresses.add(address + ":" + dirPort);
-    }
-    addresses.addAll(orAddressesAndPorts);
-    this.lastAddresses.put(lastChangedAddresses, addresses);
-    this.aSNumber = aSNumber;
-    this.contact = contact;
-    this.recommendedVersion = recommendedVersion;
-    this.familyFingerprints = familyFingerprints;
-  }
-
-  public static NodeStatus fromString(String documentString) {
-    boolean isRelay = false;
-    String nickname = null, fingerprint = null, address = null,
-        countryCode = null, hostName = null, defaultPolicy = null,
-        portList = null, aSNumber = null, contact = null;
-    SortedSet<String> orAddressesAndPorts = null, exitAddresses = null,
-        relayFlags = null, familyFingerprints = null;
-    long lastSeenMillis = -1L, consensusWeight = -1L,
-        lastRdnsLookup = -1L, firstSeenMillis = -1L,
-        lastChangedAddresses = -1L;
-    int orPort = -1, dirPort = -1;
-    Boolean recommendedVersion = null;
-    try {
-      String separator = documentString.contains("\t") ? "\t" : " ";
-      String[] parts = documentString.trim().split(separator);
-      isRelay = parts[0].equals("r");
-      if (parts.length < 9) {
-        System.err.println("Too few space-separated values in line '"
-            + documentString.trim() + "'.  Skipping.");
-        return null;
-      }
-      nickname = parts[1];
-      fingerprint = parts[2];
-      orAddressesAndPorts = new TreeSet<String>();
-      exitAddresses = new TreeSet<String>();
-      String addresses = parts[3];
-      if (addresses.contains(";")) {
-        String[] addressParts = addresses.split(";", -1);
-        if (addressParts.length != 3) {
-          System.err.println("Invalid addresses entry in line '"
-              + documentString.trim() + "'.  Skipping.");
-          return null;
-        }
-        address = addressParts[0];
-        if (addressParts[1].length() > 0) {
-          orAddressesAndPorts.addAll(Arrays.asList(
-              addressParts[1].split("\\+")));
-        }
-        if (addressParts[2].length() > 0) {
-          exitAddresses.addAll(Arrays.asList(
-              addressParts[2].split("\\+")));
-        }
-      } else {
-        address = addresses;
-      }
-      lastSeenMillis = DateTimeHelper.parse(parts[4] + " " + parts[5]);
-      if (lastSeenMillis < 0L) {
-        System.err.println("Parse exception while parsing node status "
-            + "line '" + documentString + "'.  Skipping.");
-        return null;
-      }
-      orPort = Integer.parseInt(parts[6]);
-      dirPort = Integer.parseInt(parts[7]);
-      relayFlags = new TreeSet<String>();
-      if (parts[8].length() > 0) {
-        relayFlags.addAll(Arrays.asList(parts[8].split(",")));
-      }
-      if (parts.length > 9) {
-        consensusWeight = Long.parseLong(parts[9]);
-      }
-      if (parts.length > 10) {
-        countryCode = parts[10];
-      }
-      if (parts.length > 12) {
-        hostName = parts[11].equals("null") ? null : parts[11];
-        lastRdnsLookup = Long.parseLong(parts[12]);
-      }
-      if (parts.length > 14) {
-        if (!parts[13].equals("null")) {
-          defaultPolicy = parts[13];
-        }
-        if (!parts[14].equals("null")) {
-          portList = parts[14];
-        }
-      }
-      firstSeenMillis = lastSeenMillis;
-      if (parts.length > 16) {
-        firstSeenMillis = DateTimeHelper.parse(parts[15] + " "
-            + parts[16]);
-        if (firstSeenMillis < 0L) {
-          System.err.println("Parse exception while parsing node status "
-              + "line '" + documentString + "'.  Skipping.");
-          return null;
-        }
-      }
-      lastChangedAddresses = lastSeenMillis;
-      if (parts.length > 18 && !parts[17].equals("null")) {
-        lastChangedAddresses = DateTimeHelper.parse(parts[17] + " "
-            + parts[18]);
-        if (lastChangedAddresses < 0L) {
-          System.err.println("Parse exception while parsing node status "
-              + "line '" + documentString + "'.  Skipping.");
-          return null;
-        }
-      }
-      if (parts.length > 19) {
-        aSNumber = parts[19];
-      }
-      if (parts.length > 20) {
-        contact = parts[20];
-      }
-      if (parts.length > 21) {
-        recommendedVersion = parts[21].equals("null") ? null :
-            parts[21].equals("true");
-      }
-      if (parts.length > 22 && !parts[22].equals("null")) {
-        familyFingerprints = new TreeSet<String>(Arrays.asList(
-            parts[22].split(";")));
-      }
-    } catch (NumberFormatException e) {
-      System.err.println("Number format exception while parsing node "
-          + "status line '" + documentString + "': " + e.getMessage()
-          + ".  Skipping.");
-      return null;
-    } catch (Exception e) {
-      /* This catch block is only here to handle yet unknown errors.  It
-       * should go away once we're sure what kind of errors can occur. */
-      System.err.println("Unknown exception while parsing node status "
-          + "line '" + documentString + "': " + e.getMessage() + ".  "
-          + "Skipping.");
-      return null;
-    }
-    NodeStatus newNodeStatus = new NodeStatus(isRelay, nickname,
-        fingerprint, address, orAddressesAndPorts, exitAddresses,
-        lastSeenMillis, orPort, dirPort, relayFlags, consensusWeight,
-        countryCode, hostName, lastRdnsLookup, defaultPolicy, portList,
-        firstSeenMillis, lastChangedAddresses, aSNumber, contact,
-        recommendedVersion, familyFingerprints);
-    return newNodeStatus;
-  }
-
-  public void update(NodeStatus newNodeStatus) {
-    if (newNodeStatus.lastSeenMillis > this.lastSeenMillis) {
-      this.nickname = newNodeStatus.nickname;
-      this.address = newNodeStatus.address;
-      this.orAddressesAndPorts = newNodeStatus.orAddressesAndPorts;
-      this.lastSeenMillis = newNodeStatus.lastSeenMillis;
-      this.orPort = newNodeStatus.orPort;
-      this.dirPort = newNodeStatus.dirPort;
-      this.relayFlags = newNodeStatus.relayFlags;
-      this.consensusWeight = newNodeStatus.consensusWeight;
-      this.countryCode = newNodeStatus.countryCode;
-      this.defaultPolicy = newNodeStatus.defaultPolicy;
-      this.portList = newNodeStatus.portList;
-      this.aSNumber = newNodeStatus.aSNumber;
-      this.recommendedVersion = newNodeStatus.recommendedVersion;
-    }
-    if (this.isRelay && newNodeStatus.isRelay) {
-      this.lastAddresses.putAll(newNodeStatus.lastAddresses);
-    }
-    this.firstSeenMillis = Math.min(newNodeStatus.firstSeenMillis,
-        this.firstSeenMillis);
-  }
-
-  public String toString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append(this.isRelay ? "r" : "b");
-    sb.append("\t" + this.nickname);
-    sb.append("\t" + this.fingerprint);
-    sb.append("\t" + this.address + ";");
-    int written = 0;
-    for (String orAddressAndPort : this.orAddressesAndPorts) {
-      sb.append((written++ > 0 ? "+" : "") + orAddressAndPort);
-    }
-    sb.append(";");
-    if (this.isRelay) {
-      written = 0;
-      for (String exitAddress : this.exitAddresses) {
-        sb.append((written++ > 0 ? "+" : "")
-            + exitAddress);
-      }
-    }
-    sb.append("\t" + DateTimeHelper.format(this.lastSeenMillis,
-        DateTimeHelper.ISO_DATETIME_TAB_FORMAT));
-    sb.append("\t" + this.orPort);
-    sb.append("\t" + this.dirPort + "\t");
-    written = 0;
-    for (String relayFlag : this.relayFlags) {
-      sb.append((written++ > 0 ? "," : "") + relayFlag);
-    }
-    if (this.isRelay) {
-      sb.append("\t" + String.valueOf(this.consensusWeight));
-      sb.append("\t"
-          + (this.countryCode != null ? this.countryCode : "??"));
-      sb.append("\t" + (this.hostName != null ? this.hostName : "null"));
-      sb.append("\t" + String.valueOf(this.lastRdnsLookup));
-      sb.append("\t" + (this.defaultPolicy != null ? this.defaultPolicy
-          : "null"));
-      sb.append("\t" + (this.portList != null ? this.portList : "null"));
-    } else {
-      sb.append("\t-1\t??\tnull\t-1\tnull\tnull");
-    }
-    sb.append("\t" + DateTimeHelper.format(this.firstSeenMillis,
-        DateTimeHelper.ISO_DATETIME_TAB_FORMAT));
-    if (this.isRelay) {
-      sb.append("\t" + DateTimeHelper.format(
-          this.getLastChangedOrAddress(),
-          DateTimeHelper.ISO_DATETIME_TAB_FORMAT));
-      sb.append("\t" + (this.aSNumber != null ? this.aSNumber : "null"));
-    } else {
-      sb.append("\tnull\tnull\tnull");
-    }
-    sb.append("\t" + (this.contact != null ? this.contact : ""));
-    sb.append("\t" + (this.recommendedVersion == null ? "null" :
-        this.recommendedVersion ? "true" : "false"));
-    if (this.familyFingerprints == null ||
-        this.familyFingerprints.isEmpty()) {
-      sb.append("\tnull");
-    } else {
-      sb.append("\t");
-      written = 0;
-      for (String familyFingerprint : this.familyFingerprints) {
-        sb.append((written++ > 0 ? ";" : "") + familyFingerprint);
-      }
-    }
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/RequestHandler.java b/src/org/torproject/onionoo/RequestHandler.java
deleted file mode 100644
index 58ec17a..0000000
--- a/src/org/torproject/onionoo/RequestHandler.java
+++ /dev/null
@@ -1,548 +0,0 @@
-/* Copyright 2011--2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-
-public class RequestHandler {
-
-  private NodeIndex nodeIndex;
-
-  private DocumentStore documentStore;
-
-  public RequestHandler(NodeIndex nodeIndex) {
-    this.nodeIndex = nodeIndex;
-    this.documentStore = ApplicationFactory.getDocumentStore();
-  }
-
-  private String resourceType;
-  public void setResourceType(String resourceType) {
-    this.resourceType = resourceType;
-  }
-
-  private String type;
-  public void setType(String type) {
-    this.type = type;
-  }
-
-  private String running;
-  public void setRunning(String running) {
-    this.running = running;
-  }
-
-  private String[] search;
-  public void setSearch(String[] search) {
-    this.search = new String[search.length];
-    System.arraycopy(search, 0, this.search, 0, search.length);
-  }
-
-  private String lookup;
-  public void setLookup(String lookup) {
-    this.lookup = lookup;
-  }
-
-  private String fingerprint;
-  public void setFingerprint(String fingerprint) {
-    this.fingerprint = fingerprint;
-  }
-
-  private String country;
-  public void setCountry(String country) {
-    this.country = country;
-  }
-
-  private String as;
-  public void setAs(String as) {
-    this.as = as;
-  }
-
-  private String flag;
-  public void setFlag(String flag) {
-    this.flag = flag;
-  }
-
-  private String[] contact;
-  public void setContact(String[] contact) {
-    this.contact = new String[contact.length];
-    System.arraycopy(contact, 0, this.contact, 0, contact.length);
-  }
-
-  private String[] order;
-  public void setOrder(String[] order) {
-    this.order = new String[order.length];
-    System.arraycopy(order, 0, this.order, 0, order.length);
-  }
-
-  private String offset;
-  public void setOffset(String offset) {
-    this.offset = offset;
-  }
-
-  private String limit;
-  public void setLimit(String limit) {
-    this.limit = limit;
-  }
-
-  private int[] firstSeenDays;
-  public void setFirstSeenDays(int[] firstSeenDays) {
-    this.firstSeenDays = new int[firstSeenDays.length];
-    System.arraycopy(firstSeenDays, 0, this.firstSeenDays, 0,
-        firstSeenDays.length);
-  }
-
-  private int[] lastSeenDays;
-  public void setLastSeenDays(int[] lastSeenDays) {
-    this.lastSeenDays = new int[lastSeenDays.length];
-    System.arraycopy(lastSeenDays, 0, this.lastSeenDays, 0,
-        lastSeenDays.length);
-  }
-
-  private String family;
-  public void setFamily(String family) {
-    this.family = family;
-  }
-
-  private Map<String, SummaryDocument> filteredRelays =
-      new HashMap<String, SummaryDocument>();
-
-  private Map<String, SummaryDocument> filteredBridges =
-      new HashMap<String, SummaryDocument>();
-
-  public void handleRequest() {
-    this.filteredRelays.putAll(
-        this.nodeIndex.getRelayFingerprintSummaryLines());
-    this.filteredBridges.putAll(
-        this.nodeIndex.getBridgeFingerprintSummaryLines());
-    this.filterByResourceType();
-    this.filterByType();
-    this.filterByRunning();
-    this.filterBySearchTerms();
-    this.filterByLookup();
-    this.filterByFingerprint();
-    this.filterByCountryCode();
-    this.filterByASNumber();
-    this.filterByFlag();
-    this.filterNodesByFirstSeenDays();
-    this.filterNodesByLastSeenDays();
-    this.filterByContact();
-    this.filterByFamily();
-    this.order();
-    this.offset();
-    this.limit();
-  }
-
-
-  private void filterByResourceType() {
-    if (this.resourceType.equals("clients")) {
-      this.filteredRelays.clear();
-    }
-    if (this.resourceType.equals("weights")) {
-      this.filteredBridges.clear();
-    }
-  }
-
-  private void filterByType() {
-    if (this.type == null) {
-      return;
-    } else if (this.type.equals("relay")) {
-      this.filteredBridges.clear();
-    } else {
-      this.filteredRelays.clear();
-    }
-  }
-
-  private void filterByRunning() {
-    if (this.running == null) {
-      return;
-    }
-    boolean runningRequested = this.running.equals("true");
-    Set<String> removeRelays = new HashSet<String>();
-    for (Map.Entry<String, SummaryDocument> e :
-        filteredRelays.entrySet()) {
-      if (e.getValue().isRunning() != runningRequested) {
-        removeRelays.add(e.getKey());
-      }
-    }
-    for (String fingerprint : removeRelays) {
-      this.filteredRelays.remove(fingerprint);
-    }
-    Set<String> removeBridges = new HashSet<String>();
-    for (Map.Entry<String, SummaryDocument> e :
-        filteredBridges.entrySet()) {
-      if (e.getValue().isRunning() != runningRequested) {
-        removeBridges.add(e.getKey());
-      }
-    }
-    for (String fingerprint : removeBridges) {
-      this.filteredBridges.remove(fingerprint);
-    }
-  }
-
-  private void filterBySearchTerms() {
-    if (this.search == null) {
-      return;
-    }
-    for (String searchTerm : this.search) {
-      filterBySearchTerm(searchTerm);
-    }
-  }
-
-  private void filterBySearchTerm(String searchTerm) {
-    Set<String> removeRelays = new HashSet<String>();
-    for (Map.Entry<String, SummaryDocument> e :
-        filteredRelays.entrySet()) {
-      String fingerprint = e.getKey();
-      SummaryDocument entry = e.getValue();
-      boolean lineMatches = false;
-      String nickname = entry.getNickname() != null ?
-          entry.getNickname().toLowerCase() : "unnamed";
-      if (searchTerm.startsWith("$")) {
-        /* Search is for $-prefixed fingerprint. */
-        if (fingerprint.startsWith(
-            searchTerm.substring(1).toUpperCase())) {
-          /* $-prefixed fingerprint matches. */
-          lineMatches = true;
-        }
-      } else if (nickname.contains(searchTerm.toLowerCase())) {
-        /* Nickname matches. */
-        lineMatches = true;
-      } else if (fingerprint.startsWith(searchTerm.toUpperCase())) {
-        /* Non-$-prefixed fingerprint matches. */
-        lineMatches = true;
-      } else {
-        List<String> addresses = entry.getAddresses();
-        for (String address : addresses) {
-          if (address.startsWith(searchTerm.toLowerCase())) {
-            /* Address matches. */
-            lineMatches = true;
-            break;
-          }
-        }
-      }
-      if (!lineMatches) {
-        removeRelays.add(e.getKey());
-      }
-    }
-    for (String fingerprint : removeRelays) {
-      this.filteredRelays.remove(fingerprint);
-    }
-    Set<String> removeBridges = new HashSet<String>();
-    for (Map.Entry<String, SummaryDocument> e :
-        filteredBridges.entrySet()) {
-      String hashedFingerprint = e.getKey();
-      SummaryDocument entry = e.getValue();
-      boolean lineMatches = false;
-      String nickname = entry.getNickname() != null ?
-          entry.getNickname().toLowerCase() : "unnamed";
-      if (searchTerm.startsWith("$")) {
-        /* Search is for $-prefixed hashed fingerprint. */
-        if (hashedFingerprint.startsWith(
-            searchTerm.substring(1).toUpperCase())) {
-          /* $-prefixed hashed fingerprint matches. */
-          lineMatches = true;
-        }
-      } else if (nickname.contains(searchTerm.toLowerCase())) {
-        /* Nickname matches. */
-        lineMatches = true;
-      } else if (hashedFingerprint.startsWith(searchTerm.toUpperCase())) {
-        /* Non-$-prefixed hashed fingerprint matches. */
-        lineMatches = true;
-      }
-      if (!lineMatches) {
-        removeBridges.add(e.getKey());
-      }
-    }
-    for (String fingerprint : removeBridges) {
-      this.filteredBridges.remove(fingerprint);
-    }
-  }
-
-  private void filterByLookup() {
-    if (this.lookup == null) {
-      return;
-    }
-    String fingerprint = this.lookup;
-    SummaryDocument relayLine = this.filteredRelays.get(fingerprint);
-    this.filteredRelays.clear();
-    if (relayLine != null) {
-      this.filteredRelays.put(fingerprint, relayLine);
-    }
-    SummaryDocument bridgeLine = this.filteredBridges.get(fingerprint);
-    this.filteredBridges.clear();
-    if (bridgeLine != null) {
-      this.filteredBridges.put(fingerprint, bridgeLine);
-    }
-  }
-
-  private void filterByFingerprint() {
-    if (this.fingerprint == null) {
-      return;
-    }
-    this.filteredRelays.clear();
-    this.filteredBridges.clear();
-    String fingerprint = this.fingerprint;
-    SummaryDocument entry = this.documentStore.retrieve(
-        SummaryDocument.class, true, fingerprint);
-    if (entry != null) {
-      if (entry.isRelay()) {
-        this.filteredRelays.put(fingerprint, entry);
-      } else {
-        this.filteredBridges.put(fingerprint, entry);
-      }
-    }
-  }
-
-  private void filterByCountryCode() {
-    if (this.country == null) {
-      return;
-    }
-    String countryCode = this.country.toLowerCase();
-    if (!this.nodeIndex.getRelaysByCountryCode().containsKey(
-        countryCode)) {
-      this.filteredRelays.clear();
-    } else {
-      Set<String> relaysWithCountryCode =
-          this.nodeIndex.getRelaysByCountryCode().get(countryCode);
-      Set<String> removeRelays = new HashSet<String>();
-      for (String fingerprint : this.filteredRelays.keySet()) {
-        if (!relaysWithCountryCode.contains(fingerprint)) {
-          removeRelays.add(fingerprint);
-        }
-      }
-      for (String fingerprint : removeRelays) {
-        this.filteredRelays.remove(fingerprint);
-      }
-    }
-    this.filteredBridges.clear();
-  }
-
-  private void filterByASNumber() {
-    if (this.as == null) {
-      return;
-    }
-    String aSNumber = this.as.toUpperCase();
-    if (!aSNumber.startsWith("AS")) {
-      aSNumber = "AS" + aSNumber;
-    }
-    if (!this.nodeIndex.getRelaysByASNumber().containsKey(aSNumber)) {
-      this.filteredRelays.clear();
-    } else {
-      Set<String> relaysWithASNumber =
-          this.nodeIndex.getRelaysByASNumber().get(aSNumber);
-      Set<String> removeRelays = new HashSet<String>();
-      for (String fingerprint : this.filteredRelays.keySet()) {
-        if (!relaysWithASNumber.contains(fingerprint)) {
-          removeRelays.add(fingerprint);
-        }
-      }
-      for (String fingerprint : removeRelays) {
-        this.filteredRelays.remove(fingerprint);
-      }
-    }
-    this.filteredBridges.clear();
-  }
-
-  private void filterByFlag() {
-    if (this.flag == null) {
-      return;
-    }
-    String flag = this.flag.toLowerCase();
-    if (!this.nodeIndex.getRelaysByFlag().containsKey(flag)) {
-      this.filteredRelays.clear();
-    } else {
-      Set<String> relaysWithFlag = this.nodeIndex.getRelaysByFlag().get(
-          flag);
-      Set<String> removeRelays = new HashSet<String>();
-      for (String fingerprint : this.filteredRelays.keySet()) {
-        if (!relaysWithFlag.contains(fingerprint)) {
-          removeRelays.add(fingerprint);
-        }
-      }
-      for (String fingerprint : removeRelays) {
-        this.filteredRelays.remove(fingerprint);
-      }
-    }
-    if (!this.nodeIndex.getBridgesByFlag().containsKey(flag)) {
-      this.filteredBridges.clear();
-    } else {
-      Set<String> bridgesWithFlag = this.nodeIndex.getBridgesByFlag().get(
-          flag);
-      Set<String> removeBridges = new HashSet<String>();
-      for (String fingerprint : this.filteredBridges.keySet()) {
-        if (!bridgesWithFlag.contains(fingerprint)) {
-          removeBridges.add(fingerprint);
-        }
-      }
-      for (String fingerprint : removeBridges) {
-        this.filteredBridges.remove(fingerprint);
-      }
-    }
-  }
-
-  private void filterNodesByFirstSeenDays() {
-    if (this.firstSeenDays == null) {
-      return;
-    }
-    filterNodesByDays(this.filteredRelays,
-        this.nodeIndex.getRelaysByFirstSeenDays(), this.firstSeenDays);
-    filterNodesByDays(this.filteredBridges,
-        this.nodeIndex.getBridgesByFirstSeenDays(), this.firstSeenDays);
-  }
-
-  private void filterNodesByLastSeenDays() {
-    if (this.lastSeenDays == null) {
-      return;
-    }
-    filterNodesByDays(this.filteredRelays,
-        this.nodeIndex.getRelaysByLastSeenDays(), this.lastSeenDays);
-    filterNodesByDays(this.filteredBridges,
-        this.nodeIndex.getBridgesByLastSeenDays(), this.lastSeenDays);
-  }
-
-  private void filterNodesByDays(
-      Map<String, SummaryDocument> filteredNodes,
-      SortedMap<Integer, Set<String>> nodesByDays, int[] days) {
-    Set<String> removeNodes = new HashSet<String>();
-    for (Set<String> nodes : nodesByDays.headMap(days[0]).values()) {
-      removeNodes.addAll(nodes);
-    }
-    if (days[1] < Integer.MAX_VALUE) {
-      for (Set<String> nodes :
-          nodesByDays.tailMap(days[1] + 1).values()) {
-        removeNodes.addAll(nodes);
-      }
-    }
-    for (String fingerprint : removeNodes) {
-      filteredNodes.remove(fingerprint);
-    }
-  }
-
-  private void filterByContact() {
-    if (this.contact == null) {
-      return;
-    }
-    Set<String> removeRelays = new HashSet<String>();
-    for (Map.Entry<String, Set<String>> e :
-        this.nodeIndex.getRelaysByContact().entrySet()) {
-      String contact = e.getKey();
-      for (String contactPart : this.contact) {
-        if (contact == null ||
-            !contact.contains(contactPart.toLowerCase())) {
-          removeRelays.addAll(e.getValue());
-          break;
-        }
-      }
-    }
-    for (String fingerprint : removeRelays) {
-      this.filteredRelays.remove(fingerprint);
-    }
-    this.filteredBridges.clear();
-  }
-
-  private void filterByFamily() {
-    if (this.family == null) {
-      return;
-    }
-    Set<String> removeRelays = new HashSet<String>(
-        this.filteredRelays.keySet());
-    removeRelays.remove(this.family);
-    if (this.nodeIndex.getRelaysByFamily().containsKey(this.family)) {
-      removeRelays.removeAll(this.nodeIndex.getRelaysByFamily().
-          get(this.family));
-    }
-    for (String fingerprint : removeRelays) {
-      this.filteredRelays.remove(fingerprint);
-    }
-    this.filteredBridges.clear();
-  }
-
-  private void order() {
-    if (this.order != null && this.order.length == 1) {
-      List<String> orderBy = new ArrayList<String>(
-          this.nodeIndex.getRelaysByConsensusWeight());
-      if (this.order[0].startsWith("-")) {
-        Collections.reverse(orderBy);
-      }
-      for (String relay : orderBy) {
-        if (this.filteredRelays.containsKey(relay) &&
-            !this.orderedRelays.contains(filteredRelays.get(relay))) {
-          this.orderedRelays.add(this.filteredRelays.remove(relay));
-        }
-      }
-      for (String relay : this.filteredRelays.keySet()) {
-        if (!this.orderedRelays.contains(this.filteredRelays.get(relay))) {
-          this.orderedRelays.add(this.filteredRelays.remove(relay));
-        }
-      }
-      Set<SummaryDocument> uniqueBridges = new HashSet<SummaryDocument>(
-          this.filteredBridges.values());
-      this.orderedBridges.addAll(uniqueBridges);
-    } else {
-      Set<SummaryDocument> uniqueRelays = new HashSet<SummaryDocument>(
-          this.filteredRelays.values());
-      this.orderedRelays.addAll(uniqueRelays);
-      Set<SummaryDocument> uniqueBridges = new HashSet<SummaryDocument>(
-          this.filteredBridges.values());
-      this.orderedBridges.addAll(uniqueBridges);
-    }
-  }
-
-  private void offset() {
-    if (this.offset == null) {
-      return;
-    }
-    int offsetValue = Integer.parseInt(this.offset);
-    while (offsetValue-- > 0 &&
-        (!this.orderedRelays.isEmpty() ||
-        !this.orderedBridges.isEmpty())) {
-      if (!this.orderedRelays.isEmpty()) {
-        this.orderedRelays.remove(0);
-      } else {
-        this.orderedBridges.remove(0);
-      }
-    }
-  }
-
-  private void limit() {
-    if (this.limit == null) {
-      return;
-    }
-    int limitValue = Integer.parseInt(this.limit);
-    while (!this.orderedRelays.isEmpty() &&
-        limitValue < this.orderedRelays.size()) {
-      this.orderedRelays.remove(this.orderedRelays.size() - 1);
-    }
-    limitValue -= this.orderedRelays.size();
-    while (!this.orderedBridges.isEmpty() &&
-        limitValue < this.orderedBridges.size()) {
-      this.orderedBridges.remove(this.orderedBridges.size() - 1);
-    }
-  }
-
-  private List<SummaryDocument> orderedRelays =
-      new ArrayList<SummaryDocument>();
-  public List<SummaryDocument> getOrderedRelays() {
-    return this.orderedRelays;
-  }
-
-  private List<SummaryDocument> orderedBridges =
-      new ArrayList<SummaryDocument>();
-  public List<SummaryDocument> getOrderedBridges() {
-    return this.orderedBridges;
-  }
-
-  public String getRelaysPublishedString() {
-    return this.nodeIndex.getRelaysPublishedString();
-  }
-
-  public String getBridgesPublishedString() {
-    return this.nodeIndex.getBridgesPublishedString();
-  }
-}
diff --git a/src/org/torproject/onionoo/ResourceServlet.java b/src/org/torproject/onionoo/ResourceServlet.java
deleted file mode 100644
index efce86e..0000000
--- a/src/org/torproject/onionoo/ResourceServlet.java
+++ /dev/null
@@ -1,448 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.regex.Pattern;
-
-import javax.servlet.ServletConfig;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class ResourceServlet extends HttpServlet {
-
-  private static final long serialVersionUID = 7236658979947465319L;
-
-  private boolean maintenanceMode = false;
-
-  /* Called by servlet container, not by test class. */
-  public void init(ServletConfig config) throws ServletException {
-    super.init(config);
-    this.maintenanceMode =
-        config.getInitParameter("maintenance") != null &&
-        config.getInitParameter("maintenance").equals("1");
-  }
-
-  public long getLastModified(HttpServletRequest request) {
-    if (this.maintenanceMode) {
-      return super.getLastModified(request);
-    } else {
-      return ApplicationFactory.getNodeIndexer().getLastIndexed(
-          DateTimeHelper.TEN_SECONDS);
-    }
-  }
-
-  protected static class HttpServletRequestWrapper {
-    private HttpServletRequest request;
-    protected HttpServletRequestWrapper(HttpServletRequest request) {
-      this.request = request;
-    }
-    protected String getRequestURI() {
-      return this.request.getRequestURI();
-    }
-    @SuppressWarnings("rawtypes")
-    protected Map getParameterMap() {
-      return this.request.getParameterMap();
-    }
-    protected String[] getParameterValues(String parameterKey) {
-      return this.request.getParameterValues(parameterKey);
-    }
-  }
-
-  protected static class HttpServletResponseWrapper {
-    private HttpServletResponse response = null;
-    protected HttpServletResponseWrapper(HttpServletResponse response) {
-      this.response = response;
-    }
-    protected void sendError(int errorStatusCode) throws IOException {
-      this.response.sendError(errorStatusCode);
-    }
-    protected void setHeader(String headerName, String headerValue) {
-      this.response.setHeader(headerName, headerValue);
-    }
-    protected void setContentType(String contentType) {
-      this.response.setContentType(contentType);
-    }
-    protected void setCharacterEncoding(String characterEncoding) {
-      this.response.setCharacterEncoding(characterEncoding);
-    }
-    protected PrintWriter getWriter() throws IOException {
-      return this.response.getWriter();
-    }
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-    HttpServletRequestWrapper requestWrapper =
-        new HttpServletRequestWrapper(request);
-    HttpServletResponseWrapper responseWrapper =
-        new HttpServletResponseWrapper(response);
-    this.doGet(requestWrapper, responseWrapper);
-  }
-
-  public void doGet(HttpServletRequestWrapper request,
-      HttpServletResponseWrapper response) throws IOException {
-
-    if (this.maintenanceMode) {
-      response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
-      return;
-    }
-
-    if (ApplicationFactory.getNodeIndexer().getLastIndexed(
-        DateTimeHelper.TEN_SECONDS) + DateTimeHelper.SIX_HOURS
-        < ApplicationFactory.getTime().currentTimeMillis()) {
-      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      return;
-    }
-
-    NodeIndex nodeIndex = ApplicationFactory.getNodeIndexer().
-        getLatestNodeIndex(DateTimeHelper.TEN_SECONDS);
-    if (nodeIndex == null) {
-      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      return;
-    }
-
-    String uri = request.getRequestURI();
-    if (uri.startsWith("/onionoo/")) {
-      uri = uri.substring("/onionoo".length());
-    }
-    String resourceType = null;
-    if (uri.startsWith("/summary")) {
-      resourceType = "summary";
-    } else if (uri.startsWith("/details")) {
-      resourceType = "details";
-    } else if (uri.startsWith("/bandwidth")) {
-      resourceType = "bandwidth";
-    } else if (uri.startsWith("/weights")) {
-      resourceType = "weights";
-    } else if (uri.startsWith("/clients")) {
-      resourceType = "clients";
-    } else if (uri.startsWith("/uptime")) {
-      resourceType = "uptime";
-    } else {
-      response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-      return;
-    }
-
-    RequestHandler rh = new RequestHandler(nodeIndex);
-    rh.setResourceType(resourceType);
-
-    /* Extract parameters either from the old-style URI or from request
-     * parameters. */
-    Map<String, String> parameterMap = new HashMap<String, String>();
-    for (Object parameterKey : request.getParameterMap().keySet()) {
-      String[] parameterValues =
-          request.getParameterValues((String) parameterKey);
-      parameterMap.put((String) parameterKey, parameterValues[0]);
-    }
-
-    /* Make sure that the request doesn't contain any unknown
-     * parameters. */
-    Set<String> knownParameters = new HashSet<String>(Arrays.asList((
-        "type,running,search,lookup,fingerprint,country,as,flag,"
-        + "first_seen_days,last_seen_days,contact,order,limit,offset,"
-        + "fields,family").split(",")));
-    for (String parameterKey : parameterMap.keySet()) {
-      if (!knownParameters.contains(parameterKey)) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-    }
-
-    /* Filter relays and bridges matching the request. */
-    if (parameterMap.containsKey("type")) {
-      String typeParameterValue = parameterMap.get("type").toLowerCase();
-      boolean relaysRequested = true;
-      if (typeParameterValue.equals("bridge")) {
-        relaysRequested = false;
-      } else if (!typeParameterValue.equals("relay")) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setType(relaysRequested ? "relay" : "bridge");
-    }
-    if (parameterMap.containsKey("running")) {
-      String runningParameterValue =
-          parameterMap.get("running").toLowerCase();
-      boolean runningRequested = true;
-      if (runningParameterValue.equals("false")) {
-        runningRequested = false;
-      } else if (!runningParameterValue.equals("true")) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setRunning(runningRequested ? "true" : "false");
-    }
-    if (parameterMap.containsKey("search")) {
-      String[] searchTerms = this.parseSearchParameters(
-          parameterMap.get("search"));
-      if (searchTerms == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setSearch(searchTerms);
-    }
-    if (parameterMap.containsKey("lookup")) {
-      String lookupParameter = this.parseFingerprintParameter(
-          parameterMap.get("lookup"));
-      if (lookupParameter == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      String fingerprint = lookupParameter.toUpperCase();
-      rh.setLookup(fingerprint);
-    }
-    if (parameterMap.containsKey("fingerprint")) {
-      String fingerprintParameter = this.parseFingerprintParameter(
-          parameterMap.get("fingerprint"));
-      if (fingerprintParameter == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      String fingerprint = fingerprintParameter.toUpperCase();
-      rh.setFingerprint(fingerprint);
-    }
-    if (parameterMap.containsKey("country")) {
-      String countryCodeParameter = this.parseCountryCodeParameter(
-          parameterMap.get("country"));
-      if (countryCodeParameter == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setCountry(countryCodeParameter);
-    }
-    if (parameterMap.containsKey("as")) {
-      String aSNumberParameter = this.parseASNumberParameter(
-          parameterMap.get("as"));
-      if (aSNumberParameter == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setAs(aSNumberParameter);
-    }
-    if (parameterMap.containsKey("flag")) {
-      String flagParameter = this.parseFlagParameter(
-          parameterMap.get("flag"));
-      if (flagParameter == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setFlag(flagParameter);
-    }
-    if (parameterMap.containsKey("first_seen_days")) {
-      int[] days = this.parseDaysParameter(
-          parameterMap.get("first_seen_days"));
-      if (days == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setFirstSeenDays(days);
-    }
-    if (parameterMap.containsKey("last_seen_days")) {
-      int[] days = this.parseDaysParameter(
-          parameterMap.get("last_seen_days"));
-      if (days == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setLastSeenDays(days);
-    }
-    if (parameterMap.containsKey("contact")) {
-      String[] contactParts = this.parseContactParameter(
-          parameterMap.get("contact"));
-      if (contactParts == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setContact(contactParts);
-    }
-    if (parameterMap.containsKey("order")) {
-      String orderParameter = parameterMap.get("order").toLowerCase();
-      String orderByField = orderParameter;
-      if (orderByField.startsWith("-")) {
-        orderByField = orderByField.substring(1);
-      }
-      if (!orderByField.equals("consensus_weight")) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setOrder(new String[] { orderParameter });
-    }
-    if (parameterMap.containsKey("offset")) {
-      String offsetParameter = parameterMap.get("offset");
-      if (offsetParameter.length() > 6) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      try {
-        Integer.parseInt(offsetParameter);
-      } catch (NumberFormatException e) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setOffset(offsetParameter);
-    }
-    if (parameterMap.containsKey("limit")) {
-      String limitParameter = parameterMap.get("limit");
-      if (limitParameter.length() > 6) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      try {
-        Integer.parseInt(limitParameter);
-      } catch (NumberFormatException e) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rh.setLimit(limitParameter);
-    }
-    if (parameterMap.containsKey("family")) {
-      String familyParameter = this.parseFingerprintParameter(
-          parameterMap.get("family"));
-      if (familyParameter == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      String family = familyParameter.toUpperCase();
-      rh.setFamily(family);
-    }
-    rh.handleRequest();
-
-    ResponseBuilder rb = new ResponseBuilder();
-    rb.setResourceType(resourceType);
-    rb.setRelaysPublishedString(rh.getRelaysPublishedString());
-    rb.setBridgesPublishedString(rh.getBridgesPublishedString());
-    rb.setOrderedRelays(rh.getOrderedRelays());
-    rb.setOrderedBridges(rh.getOrderedBridges());
-    String[] fields = null;
-    if (parameterMap.containsKey("fields")) {
-      fields = this.parseFieldsParameter(parameterMap.get("fields"));
-      if (fields == null) {
-        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-        return;
-      }
-      rb.setFields(fields);
-    }
-
-    response.setHeader("Access-Control-Allow-Origin", "*");
-    response.setContentType("application/json");
-    response.setCharacterEncoding("utf-8");
-    PrintWriter pw = response.getWriter();
-    rb.buildResponse(pw);
-    pw.flush();
-    pw.close();
-  }
-
-  private static Pattern searchParameterPattern =
-      Pattern.compile("^\\$?[0-9a-fA-F]{1,40}$|" /* Fingerprint. */
-      + "^[0-9a-zA-Z\\.]{1,19}$|" /* Nickname or IPv4 address. */
-      + "^\\[[0-9a-fA-F:\\.]{1,39}\\]?$"); /* IPv6 address. */
-  private String[] parseSearchParameters(String parameter) {
-    String[] searchParameters;
-    if (parameter.contains(" ")) {
-      searchParameters = parameter.split(" ");
-    } else {
-      searchParameters = new String[] { parameter };
-    }
-    for (String searchParameter : searchParameters) {
-      if (!searchParameterPattern.matcher(searchParameter).matches()) {
-        return null;
-      }
-    }
-    return searchParameters;
-  }
-
-  private static Pattern fingerprintParameterPattern =
-      Pattern.compile("^[0-9a-zA-Z]{1,40}$");
-  private String parseFingerprintParameter(String parameter) {
-    if (!fingerprintParameterPattern.matcher(parameter).matches()) {
-      return null;
-    }
-    if (parameter.length() != 40) {
-      return null;
-    }
-    return parameter;
-  }
-
-  private static Pattern countryCodeParameterPattern =
-      Pattern.compile("^[0-9a-zA-Z]{2}$");
-  private String parseCountryCodeParameter(String parameter) {
-    if (!countryCodeParameterPattern.matcher(parameter).matches()) {
-      return null;
-    }
-    return parameter;
-  }
-
-  private static Pattern aSNumberParameterPattern =
-      Pattern.compile("^[asAS]{0,2}[0-9]{1,10}$");
-  private String parseASNumberParameter(String parameter) {
-    if (!aSNumberParameterPattern.matcher(parameter).matches()) {
-      return null;
-    }
-    return parameter;
-  }
-
-  private static Pattern flagPattern =
-      Pattern.compile("^[a-zA-Z0-9]{1,20}$");
-  private String parseFlagParameter(String parameter) {
-    if (!flagPattern.matcher(parameter).matches()) {
-      return null;
-    }
-    return parameter;
-  }
-
-  private static Pattern daysPattern = Pattern.compile("^[0-9-]{1,10}$");
-  private int[] parseDaysParameter(String parameter) {
-    if (!daysPattern.matcher(parameter).matches()) {
-      return null;
-    }
-    int x = 0, y = Integer.MAX_VALUE;
-    try {
-      if (!parameter.contains("-")) {
-        x = Integer.parseInt(parameter);
-        y = x;
-      } else {
-        String[] parts = parameter.split("-", 2);
-        if (parts[0].length() > 0) {
-          x = Integer.parseInt(parts[0]);
-        }
-        if (parts.length > 1 && parts[1].length() > 0) {
-          y = Integer.parseInt(parts[1]);
-        }
-      }
-    } catch (NumberFormatException e) {
-      return null;
-    }
-    if (x > y) {
-      return null;
-    }
-    return new int[] { x, y };
-  }
-
-  private String[] parseContactParameter(String parameter) {
-    for (char c : parameter.toCharArray()) {
-      if (c < 32 || c >= 127) {
-        return null;
-      }
-    }
-    return parameter.split(" ");
-  }
-
-  private static Pattern fieldsParameterPattern =
-      Pattern.compile("^[0-9a-zA-Z_,]*$");
-  private String[] parseFieldsParameter(String parameter) {
-    if (!fieldsParameterPattern.matcher(parameter).matches()) {
-      return null;
-    }
-    return parameter.split(",");
-  }
-}
-
diff --git a/src/org/torproject/onionoo/ResponseBuilder.java b/src/org/torproject/onionoo/ResponseBuilder.java
deleted file mode 100644
index 2086ea8..0000000
--- a/src/org/torproject/onionoo/ResponseBuilder.java
+++ /dev/null
@@ -1,311 +0,0 @@
-/* Copyright 2011--2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.List;
-
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-
-public class ResponseBuilder {
-
-  private DocumentStore documentStore;
-
-  public ResponseBuilder() {
-    this.documentStore = ApplicationFactory.getDocumentStore();
-  }
-
-  private String resourceType;
-  public void setResourceType(String resourceType) {
-    this.resourceType = resourceType;
-  }
-
-  private String relaysPublishedString;
-  public void setRelaysPublishedString(String relaysPublishedString) {
-    this.relaysPublishedString = relaysPublishedString;
-  }
-
-  private String bridgesPublishedString;
-  public void setBridgesPublishedString(String bridgesPublishedString) {
-    this.bridgesPublishedString = bridgesPublishedString;
-  }
-
-  private List<SummaryDocument> orderedRelays =
-      new ArrayList<SummaryDocument>();
-  public void setOrderedRelays(List<SummaryDocument> orderedRelays) {
-    this.orderedRelays = orderedRelays;
-  }
-
-  private List<SummaryDocument> orderedBridges =
-      new ArrayList<SummaryDocument>();
-  public void setOrderedBridges(List<SummaryDocument> orderedBridges) {
-    this.orderedBridges = orderedBridges;
-  }
-
-  private String[] fields;
-  public void setFields(String[] fields) {
-    this.fields = new String[fields.length];
-    System.arraycopy(fields, 0, this.fields, 0, fields.length);
-  }
-
-  public void buildResponse(PrintWriter pw) {
-    writeRelays(orderedRelays, pw);
-    writeBridges(orderedBridges, pw);
-  }
-
-  private void writeRelays(List<SummaryDocument> relays, PrintWriter pw) {
-    pw.write("{\"relays_published\":\"" + relaysPublishedString
-        + "\",\n\"relays\":[");
-    int written = 0;
-    for (SummaryDocument entry : relays) {
-      String lines = this.formatNodeStatus(entry);
-      if (lines.length() > 0) {
-        pw.print((written++ > 0 ? ",\n" : "\n") + lines);
-      }
-    }
-    pw.print("\n],\n");
-  }
-
-  private void writeBridges(List<SummaryDocument> bridges,
-      PrintWriter pw) {
-    pw.write("\"bridges_published\":\"" + bridgesPublishedString
-        + "\",\n\"bridges\":[");
-    int written = 0;
-    for (SummaryDocument entry : bridges) {
-      String lines = this.formatNodeStatus(entry);
-      if (lines.length() > 0) {
-        pw.print((written++ > 0 ? ",\n" : "\n") + lines);
-      }
-    }
-    pw.print("\n]}\n");
-  }
-
-  private String formatNodeStatus(SummaryDocument entry) {
-    if (this.resourceType == null) {
-      return "";
-    } else if (this.resourceType.equals("summary")) {
-      return this.writeSummaryLine(entry);
-    } else if (this.resourceType.equals("details")) {
-      return this.writeDetailsLines(entry);
-    } else if (this.resourceType.equals("bandwidth")) {
-      return this.writeBandwidthLines(entry);
-    } else if (this.resourceType.equals("weights")) {
-      return this.writeWeightsLines(entry);
-    } else if (this.resourceType.equals("clients")) {
-      return this.writeClientsLines(entry);
-    } else if (this.resourceType.equals("uptime")) {
-      return this.writeUptimeLines(entry);
-    } else {
-      return "";
-    }
-  }
-
-  private String writeSummaryLine(SummaryDocument entry) {
-    return entry.isRelay() ? writeRelaySummaryLine(entry)
-        : writeBridgeSummaryLine(entry);
-  }
-
-  private String writeRelaySummaryLine(SummaryDocument entry) {
-    String nickname = !entry.getNickname().equals("Unnamed") ?
-        entry.getNickname() : null;
-    String fingerprint = entry.getFingerprint();
-    String running = entry.isRunning() ? "true" : "false";
-    List<String> addresses = entry.getAddresses();
-    StringBuilder addressesBuilder = new StringBuilder();
-    int written = 0;
-    for (String address : addresses) {
-      addressesBuilder.append((written++ > 0 ? "," : "") + "\""
-          + address.toLowerCase() + "\"");
-    }
-    return String.format("{%s\"f\":\"%s\",\"a\":[%s],\"r\":%s}",
-        (nickname == null ? "" : "\"n\":\"" + nickname + "\","),
-        fingerprint, addressesBuilder.toString(), running);
-  }
-
-  private String writeBridgeSummaryLine(SummaryDocument entry) {
-    String nickname = !entry.getNickname().equals("Unnamed") ?
-        entry.getNickname() : null;
-    String hashedFingerprint = entry.getFingerprint();
-    String running = entry.isRunning() ? "true" : "false";
-    return String.format("{%s\"h\":\"%s\",\"r\":%s}",
-         (nickname == null ? "" : "\"n\":\"" + nickname + "\","),
-         hashedFingerprint, running);
-  }
-
-  private String writeDetailsLines(SummaryDocument entry) {
-    String fingerprint = entry.getFingerprint();
-    if (this.fields != null) {
-      /* TODO Maybe there's a more elegant way (more maintainable, more
-       * efficient, etc.) to implement this? */
-      DetailsDocument detailsDocument = documentStore.retrieve(
-          DetailsDocument.class, true, fingerprint);
-      if (detailsDocument != null) {
-        DetailsDocument dd = new DetailsDocument();
-        for (String field : this.fields) {
-          if (field.equals("nickname")) {
-            dd.setNickname(detailsDocument.getNickname());
-          } else if (field.equals("fingerprint")) {
-            dd.setFingerprint(detailsDocument.getFingerprint());
-          } else if (field.equals("hashed_fingerprint")) {
-            dd.setHashedFingerprint(
-                detailsDocument.getHashedFingerprint());
-          } else if (field.equals("or_addresses")) {
-            dd.setOrAddresses(detailsDocument.getOrAddresses());
-          } else if (field.equals("exit_addresses")) {
-            dd.setExitAddresses(detailsDocument.getExitAddresses());
-          } else if (field.equals("dir_address")) {
-            dd.setDirAddress(detailsDocument.getDirAddress());
-          } else if (field.equals("last_seen")) {
-            dd.setLastSeen(detailsDocument.getLastSeen());
-          } else if (field.equals("last_changed_address_or_port")) {
-            dd.setLastChangedAddressOrPort(
-                detailsDocument.getLastChangedAddressOrPort());
-          } else if (field.equals("first_seen")) {
-            dd.setFirstSeen(detailsDocument.getFirstSeen());
-          } else if (field.equals("running")) {
-            dd.setRunning(detailsDocument.getRunning());
-          } else if (field.equals("flags")) {
-            dd.setFlags(detailsDocument.getFlags());
-          } else if (field.equals("country")) {
-            dd.setCountry(detailsDocument.getCountry());
-          } else if (field.equals("country_name")) {
-            dd.setCountryName(detailsDocument.getCountryName());
-          } else if (field.equals("region_name")) {
-            dd.setRegionName(detailsDocument.getRegionName());
-          } else if (field.equals("city_name")) {
-            dd.setCityName(detailsDocument.getCityName());
-          } else if (field.equals("latitude")) {
-            dd.setLatitude(detailsDocument.getLatitude());
-          } else if (field.equals("longitude")) {
-            dd.setLongitude(detailsDocument.getLongitude());
-          } else if (field.equals("as_number")) {
-            dd.setAsNumber(detailsDocument.getAsNumber());
-          } else if (field.equals("as_name")) {
-            dd.setAsName(detailsDocument.getAsName());
-          } else if (field.equals("consensus_weight")) {
-            dd.setConsensusWeight(detailsDocument.getConsensusWeight());
-          } else if (field.equals("host_name")) {
-            dd.setHostName(detailsDocument.getHostName());
-          } else if (field.equals("last_restarted")) {
-            dd.setLastRestarted(detailsDocument.getLastRestarted());
-          } else if (field.equals("bandwidth_rate")) {
-            dd.setBandwidthRate(detailsDocument.getBandwidthRate());
-          } else if (field.equals("bandwidth_burst")) {
-            dd.setBandwidthBurst(detailsDocument.getBandwidthBurst());
-          } else if (field.equals("observed_bandwidth")) {
-            dd.setObservedBandwidth(
-                detailsDocument.getObservedBandwidth());
-          } else if (field.equals("advertised_bandwidth")) {
-            dd.setAdvertisedBandwidth(
-                detailsDocument.getAdvertisedBandwidth());
-          } else if (field.equals("exit_policy")) {
-            dd.setExitPolicy(detailsDocument.getExitPolicy());
-          } else if (field.equals("exit_policy_summary")) {
-            dd.setExitPolicySummary(
-                detailsDocument.getExitPolicySummary());
-          } else if (field.equals("exit_policy_v6_summary")) {
-            dd.setExitPolicyV6Summary(
-                detailsDocument.getExitPolicyV6Summary());
-          } else if (field.equals("contact")) {
-            dd.setContact(detailsDocument.getContact());
-          } else if (field.equals("platform")) {
-            dd.setPlatform(detailsDocument.getPlatform());
-          } else if (field.equals("family")) {
-            dd.setFamily(detailsDocument.getFamily());
-          } else if (field.equals("advertised_bandwidth_fraction")) {
-            dd.setAdvertisedBandwidthFraction(
-                detailsDocument.getAdvertisedBandwidthFraction());
-          } else if (field.equals("consensus_weight_fraction")) {
-            dd.setConsensusWeightFraction(
-                detailsDocument.getConsensusWeightFraction());
-          } else if (field.equals("guard_probability")) {
-            dd.setGuardProbability(detailsDocument.getGuardProbability());
-          } else if (field.equals("middle_probability")) {
-            dd.setMiddleProbability(
-                detailsDocument.getMiddleProbability());
-          } else if (field.equals("exit_probability")) {
-            dd.setExitProbability(detailsDocument.getExitProbability());
-          } else if (field.equals("recommended_version")) {
-            dd.setRecommendedVersion(
-                detailsDocument.getRecommendedVersion());
-          } else if (field.equals("hibernating")) {
-            dd.setHibernating(detailsDocument.getHibernating());
-          } else if (field.equals("pool_assignment")) {
-            dd.setPoolAssignment(detailsDocument.getPoolAssignment());
-          }
-        }
-        /* Don't escape HTML characters, like < and >, contained in
-         * strings. */
-        Gson gson = new GsonBuilder().disableHtmlEscaping().create();
-        /* Whenever we provide Gson with a string containing an escaped
-         * non-ASCII character like \u00F2, it escapes the \ to \\, which
-         * we need to undo before including the string in a response. */
-        return gson.toJson(dd).replaceAll("\\\\\\\\u", "\\\\u");
-      } else {
-        // TODO We should probably log that we didn't find a details
-        // document that we expected to exist.
-        return "";
-      }
-    } else {
-      DetailsDocument detailsDocument = documentStore.retrieve(
-          DetailsDocument.class, false, fingerprint);
-      if (detailsDocument != null) {
-        return detailsDocument.getDocumentString();
-      } else {
-        // TODO We should probably log that we didn't find a details
-        // document that we expected to exist.
-        return "";
-      }
-    }
-  }
-
-  private String writeBandwidthLines(SummaryDocument entry) {
-    String fingerprint = entry.getFingerprint();
-    BandwidthDocument bandwidthDocument = this.documentStore.retrieve(
-        BandwidthDocument.class, false, fingerprint);
-    if (bandwidthDocument != null &&
-        bandwidthDocument.getDocumentString() != null) {
-      return bandwidthDocument.getDocumentString();
-    } else {
-      return "{\"fingerprint\":\"" + fingerprint.toUpperCase() + "\"}";
-    }
-  }
-
-  private String writeWeightsLines(SummaryDocument entry) {
-    String fingerprint = entry.getFingerprint();
-    WeightsDocument weightsDocument = this.documentStore.retrieve(
-        WeightsDocument.class, false, fingerprint);
-    if (weightsDocument != null &&
-        weightsDocument.getDocumentString() != null) {
-      return weightsDocument.getDocumentString();
-    } else {
-      return "{\"fingerprint\":\"" + fingerprint.toUpperCase() + "\"}";
-    }
-  }
-
-  private String writeClientsLines(SummaryDocument entry) {
-    String fingerprint = entry.getFingerprint();
-    ClientsDocument clientsDocument = this.documentStore.retrieve(
-        ClientsDocument.class, false, fingerprint);
-    if (clientsDocument != null &&
-        clientsDocument.getDocumentString() != null) {
-      return clientsDocument.getDocumentString();
-    } else {
-      return "{\"fingerprint\":\"" + fingerprint.toUpperCase() + "\"}";
-    }
-  }
-
-  private String writeUptimeLines(SummaryDocument entry) {
-    String fingerprint = entry.getFingerprint();
-    UptimeDocument uptimeDocument = this.documentStore.retrieve(
-        UptimeDocument.class, false, fingerprint);
-    if (uptimeDocument != null &&
-        uptimeDocument.getDocumentString() != null) {
-      return uptimeDocument.getDocumentString();
-    } else {
-      return "{\"fingerprint\":\"" + fingerprint.toUpperCase() + "\"}";
-    }
-  }
-}
diff --git a/src/org/torproject/onionoo/ReverseDomainNameResolver.java b/src/org/torproject/onionoo/ReverseDomainNameResolver.java
deleted file mode 100644
index 3dbf9d1..0000000
--- a/src/org/torproject/onionoo/ReverseDomainNameResolver.java
+++ /dev/null
@@ -1,174 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.net.InetAddress;
-import java.net.UnknownHostException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-public class ReverseDomainNameResolver {
-
-  private class RdnsLookupWorker extends Thread {
-    public void run() {
-      while (time.currentTimeMillis() - RDNS_LOOKUP_MAX_DURATION_MILLIS
-          <= startedRdnsLookups) {
-        String rdnsLookupJob = null;
-        synchronized (rdnsLookupJobs) {
-          for (String job : rdnsLookupJobs) {
-            rdnsLookupJob = job;
-            rdnsLookupJobs.remove(job);
-            break;
-          }
-        }
-        if (rdnsLookupJob == null) {
-          break;
-        }
-        RdnsLookupRequest request = new RdnsLookupRequest(this,
-            rdnsLookupJob);
-        request.setDaemon(true);
-        request.start();
-        try {
-          Thread.sleep(RDNS_LOOKUP_MAX_REQUEST_MILLIS);
-        } catch (InterruptedException e) {
-          /* Getting interrupted should be the default case. */
-        }
-        String hostName = request.getHostName();
-        if (hostName != null) {
-          synchronized (rdnsLookupResults) {
-            rdnsLookupResults.put(rdnsLookupJob, hostName);
-          }
-        }
-        long lookupMillis = request.getLookupMillis();
-        if (lookupMillis >= 0L) {
-          synchronized (rdnsLookupMillis) {
-            rdnsLookupMillis.add(lookupMillis);
-          }
-        }
-      }
-    }
-  }
-
-  private class RdnsLookupRequest extends Thread {
-    private RdnsLookupWorker parent;
-    private String address, hostName;
-    private long lookupStartedMillis = -1L, lookupCompletedMillis = -1L;
-    public RdnsLookupRequest(RdnsLookupWorker parent, String address) {
-      this.parent = parent;
-      this.address = address;
-    }
-    public void run() {
-      this.lookupStartedMillis = time.currentTimeMillis();
-      try {
-        String result = InetAddress.getByName(this.address).getHostName();
-        synchronized (this) {
-          this.hostName = result;
-        }
-      } catch (UnknownHostException e) {
-        /* We'll try again the next time. */
-      }
-      this.lookupCompletedMillis = time.currentTimeMillis();
-      this.parent.interrupt();
-    }
-    public synchronized String getHostName() {
-      return hostName;
-    }
-    public synchronized long getLookupMillis() {
-      return this.lookupCompletedMillis - this.lookupStartedMillis;
-    }
-  }
-
-  private Time time;
-
-  public ReverseDomainNameResolver() {
-    this.time = ApplicationFactory.getTime();
-  }
-
-  private static final long RDNS_LOOKUP_MAX_REQUEST_MILLIS =
-      DateTimeHelper.TEN_SECONDS;
-  private static final long RDNS_LOOKUP_MAX_DURATION_MILLIS =
-      DateTimeHelper.FIVE_MINUTES;
-  private static final long RDNS_LOOKUP_MAX_AGE_MILLIS =
-      DateTimeHelper.TWELVE_HOURS;
-  private static final int RDNS_LOOKUP_WORKERS_NUM = 5;
-
-  private Map<String, Long> addressLastLookupTimes;
-
-  private Set<String> rdnsLookupJobs;
-
-  private Map<String, String> rdnsLookupResults;
-
-  private List<Long> rdnsLookupMillis;
-
-  private long startedRdnsLookups;
-
-  private List<RdnsLookupWorker> rdnsLookupWorkers;
-
-  public void setAddresses(Map<String, Long> addressLastLookupTimes) {
-    this.addressLastLookupTimes = addressLastLookupTimes;
-  }
-
-  public void startReverseDomainNameLookups() {
-    this.startedRdnsLookups = this.time.currentTimeMillis();
-    this.rdnsLookupJobs = new HashSet<String>();
-    for (Map.Entry<String, Long> e :
-        this.addressLastLookupTimes.entrySet()) {
-      if (e.getValue() < this.startedRdnsLookups
-          - RDNS_LOOKUP_MAX_AGE_MILLIS) {
-        this.rdnsLookupJobs.add(e.getKey());
-      }
-    }
-    this.rdnsLookupResults = new HashMap<String, String>();
-    this.rdnsLookupMillis = new ArrayList<Long>();
-    this.rdnsLookupWorkers = new ArrayList<RdnsLookupWorker>();
-    for (int i = 0; i < RDNS_LOOKUP_WORKERS_NUM; i++) {
-      RdnsLookupWorker rdnsLookupWorker = new RdnsLookupWorker();
-      this.rdnsLookupWorkers.add(rdnsLookupWorker);
-      rdnsLookupWorker.setDaemon(true);
-      rdnsLookupWorker.start();
-    }
-  }
-
-  public void finishReverseDomainNameLookups() {
-    for (RdnsLookupWorker rdnsLookupWorker : this.rdnsLookupWorkers) {
-      try {
-        rdnsLookupWorker.join();
-      } catch (InterruptedException e) {
-        /* This is not something that we can take care of.  Just leave the
-         * worker thread alone. */
-      }
-    }
-  }
-
-  public Map<String, String> getLookupResults() {
-    synchronized (this.rdnsLookupResults) {
-      return new HashMap<String, String>(this.rdnsLookupResults);
-    }
-  }
-
-  public long getLookupStartMillis() {
-    return this.startedRdnsLookups;
-  }
-
-  public String getStatsString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append("    " + Logger.formatDecimalNumber(rdnsLookupMillis.size())
-        + " lookups performed\n");
-    if (rdnsLookupMillis.size() > 0) {
-      Collections.sort(rdnsLookupMillis);
-      sb.append("    " + Logger.formatMillis(rdnsLookupMillis.get(0))
-          + " minimum lookup time\n");
-      sb.append("    " + Logger.formatMillis(rdnsLookupMillis.get(
-          rdnsLookupMillis.size() / 2)) + " median lookup time\n");
-      sb.append("    " + Logger.formatMillis(rdnsLookupMillis.get(
-          rdnsLookupMillis.size() - 1)) + " maximum lookup time\n");
-    }
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/StatusUpdater.java b/src/org/torproject/onionoo/StatusUpdater.java
deleted file mode 100644
index fb82182..0000000
--- a/src/org/torproject/onionoo/StatusUpdater.java
+++ /dev/null
@@ -1,11 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-public interface StatusUpdater {
-
-  public abstract void updateStatuses();
-
-  public abstract String getStatsString();
-}
-
diff --git a/src/org/torproject/onionoo/SummaryDocument.java b/src/org/torproject/onionoo/SummaryDocument.java
deleted file mode 100644
index 61a3ec6..0000000
--- a/src/org/torproject/onionoo/SummaryDocument.java
+++ /dev/null
@@ -1,201 +0,0 @@
-/* Copyright 2013--2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.List;
-import java.util.SortedSet;
-import java.util.TreeSet;
-import java.util.regex.Pattern;
-
-import org.apache.commons.codec.DecoderException;
-import org.apache.commons.codec.binary.Hex;
-import org.apache.commons.codec.digest.DigestUtils;
-
-public class SummaryDocument extends Document {
-
-  private boolean t;
-  public void setRelay(boolean isRelay) {
-    this.t = isRelay;
-  }
-  public boolean isRelay() {
-    return this.t;
-  }
-
-  private String f;
-  public void setFingerprint(String fingerprint) {
-    if (fingerprint != null) {
-      Pattern fingerprintPattern = Pattern.compile("^[0-9a-fA-F]{40}$");
-      if (!fingerprintPattern.matcher(fingerprint).matches()) {
-        throw new IllegalArgumentException("Fingerprint '" + fingerprint
-            + "' is not a valid fingerprint.");
-      }
-    }
-    this.f = fingerprint;
-  }
-  public String getFingerprint() {
-    return this.f;
-  }
-
-  public String getHashedFingerprint() {
-    String hashedFingerprint = null;
-    if (this.f != null) {
-      try {
-        hashedFingerprint = DigestUtils.shaHex(Hex.decodeHex(
-            this.f.toCharArray())).toUpperCase();
-      } catch (DecoderException e) {
-        /* Format tested in setFingerprint(). */
-      }
-    }
-    return hashedFingerprint;
-  }
-
-  private String n;
-  public void setNickname(String nickname) {
-    if (nickname == null || nickname.equals("Unnamed")) {
-      this.n = null;
-    } else {
-      this.n = nickname;
-    }
-  }
-  public String getNickname() {
-    return this.n == null ? "Unnamed" : this.n;
-  }
-
-  private String[] ad;
-  public void setAddresses(List<String> addresses) {
-    this.ad = this.collectionToStringArray(addresses);
-  }
-  public List<String> getAddresses() {
-    return this.stringArrayToList(this.ad);
-  }
-
-  private String[] collectionToStringArray(
-      Collection<String> collection) {
-    String[] stringArray = null;
-    if (collection != null && !collection.isEmpty()) {
-      stringArray = new String[collection.size()];
-      int i = 0;
-      for (String string : collection) {
-        stringArray[i++] = string;
-      }
-    }
-    return stringArray;
-  }
-  private List<String> stringArrayToList(String[] stringArray) {
-    List<String> list;
-    if (stringArray == null) {
-      list = new ArrayList<String>();
-    } else {
-      list = Arrays.asList(stringArray);
-    }
-    return list;
-  }
-  private SortedSet<String> stringArrayToSortedSet(String[] stringArray) {
-    SortedSet<String> sortedSet = new TreeSet<String>();
-    if (stringArray != null) {
-      sortedSet.addAll(Arrays.asList(stringArray));
-    }
-    return sortedSet;
-  }
-
-  private String cc;
-  public void setCountryCode(String countryCode) {
-    this.cc = countryCode;
-  }
-  public String getCountryCode() {
-    return this.cc;
-  }
-
-  private String as;
-  public void setASNumber(String aSNumber) {
-    this.as = aSNumber;
-  }
-  public String getASNumber() {
-    return this.as;
-  }
-
-  private String fs;
-  public void setFirstSeenMillis(long firstSeenMillis) {
-    this.fs = DateTimeHelper.format(firstSeenMillis);
-  }
-  public long getFirstSeenMillis() {
-    return DateTimeHelper.parse(this.fs);
-  }
-
-  private String ls;
-  public void setLastSeenMillis(long lastSeenMillis) {
-    this.ls = DateTimeHelper.format(lastSeenMillis);
-  }
-  public long getLastSeenMillis() {
-    return DateTimeHelper.parse(this.ls);
-  }
-
-  private String[] rf;
-  public void setRelayFlags(SortedSet<String> relayFlags) {
-    this.rf = this.collectionToStringArray(relayFlags);
-  }
-  public SortedSet<String> getRelayFlags() {
-    return this.stringArrayToSortedSet(this.rf);
-  }
-
-  private long cw;
-  public void setConsensusWeight(long consensusWeight) {
-    this.cw = consensusWeight;
-  }
-  public long getConsensusWeight() {
-    return this.cw;
-  }
-
-  private boolean r;
-  public void setRunning(boolean isRunning) {
-    this.r = isRunning;
-  }
-  public boolean isRunning() {
-    return this.r;
-  }
-
-  private String c;
-  public void setContact(String contact) {
-    if (contact != null && contact.length() == 0) {
-      this.c = null;
-    } else {
-      this.c = contact;
-    }
-  }
-  public String getContact() {
-    return this.c;
-  }
-
-  private String[] ff;
-  public void setFamilyFingerprints(
-      SortedSet<String> familyFingerprints) {
-    this.ff = this.collectionToStringArray(familyFingerprints);
-  }
-  public SortedSet<String> getFamilyFingerprints() {
-    return this.stringArrayToSortedSet(this.ff);
-  }
-
-  public SummaryDocument(boolean isRelay, String nickname,
-      String fingerprint, List<String> addresses, long lastSeenMillis,
-      boolean running, SortedSet<String> relayFlags, long consensusWeight,
-      String countryCode, long firstSeenMillis, String aSNumber,
-      String contact, SortedSet<String> familyFingerprints) {
-    this.setRelay(isRelay);
-    this.setNickname(nickname);
-    this.setFingerprint(fingerprint);
-    this.setAddresses(addresses);
-    this.setLastSeenMillis(lastSeenMillis);
-    this.setRunning(running);
-    this.setRelayFlags(relayFlags);
-    this.setConsensusWeight(consensusWeight);
-    this.setCountryCode(countryCode);
-    this.setFirstSeenMillis(firstSeenMillis);
-    this.setASNumber(aSNumber);
-    this.setContact(contact);
-    this.setFamilyFingerprints(familyFingerprints);
-  }
-}
-
diff --git a/src/org/torproject/onionoo/SummaryDocumentWriter.java b/src/org/torproject/onionoo/SummaryDocumentWriter.java
deleted file mode 100644
index 7b257f5..0000000
--- a/src/org/torproject/onionoo/SummaryDocumentWriter.java
+++ /dev/null
@@ -1,87 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.SortedSet;
-
-public class SummaryDocumentWriter implements DocumentWriter {
-
-  private DocumentStore documentStore;
-
-  public SummaryDocumentWriter() {
-    this.documentStore = ApplicationFactory.getDocumentStore();
-  }
-
-  private int writtenDocuments = 0, deletedDocuments = 0;
-
-  public void writeDocuments() {
-    long maxLastSeenMillis = 0L;
-    for (String fingerprint : this.documentStore.list(NodeStatus.class)) {
-      NodeStatus nodeStatus = this.documentStore.retrieve(
-          NodeStatus.class, true, fingerprint);
-      if (nodeStatus != null &&
-          nodeStatus.getLastSeenMillis() > maxLastSeenMillis) {
-        maxLastSeenMillis = nodeStatus.getLastSeenMillis();
-      }
-    }
-    long cutoff = maxLastSeenMillis - DateTimeHelper.ONE_WEEK;
-    for (String fingerprint : this.documentStore.list(NodeStatus.class)) {
-      NodeStatus nodeStatus = this.documentStore.retrieve(
-          NodeStatus.class,
-          true, fingerprint);
-      if (nodeStatus == null) {
-        continue;
-      }
-      if (nodeStatus.getLastSeenMillis() < cutoff) {
-        if (this.documentStore.remove(SummaryDocument.class,
-            fingerprint)) {
-          this.deletedDocuments++;
-        }
-        continue;
-      }
-      boolean isRelay = nodeStatus.isRelay();
-      String nickname = nodeStatus.getNickname();
-      List<String> addresses = new ArrayList<String>();
-      addresses.add(nodeStatus.getAddress());
-      for (String orAddress : nodeStatus.getOrAddresses()) {
-        if (!addresses.contains(orAddress)) {
-          addresses.add(orAddress);
-        }
-      }
-      for (String exitAddress : nodeStatus.getExitAddresses()) {
-        if (!addresses.contains(exitAddress)) {
-          addresses.add(exitAddress);
-        }
-      }
-      long lastSeenMillis = nodeStatus.getLastSeenMillis();
-      boolean running = nodeStatus.getRunning();
-      SortedSet<String> relayFlags = nodeStatus.getRelayFlags();
-      long consensusWeight = nodeStatus.getConsensusWeight();
-      String countryCode = nodeStatus.getCountryCode();
-      long firstSeenMillis = nodeStatus.getFirstSeenMillis();
-      String aSNumber = nodeStatus.getASNumber();
-      String contact = nodeStatus.getContact();
-      SortedSet<String> familyFingerprints =
-          nodeStatus.getFamilyFingerprints();
-      SummaryDocument summaryDocument = new SummaryDocument(isRelay,
-          nickname, fingerprint, addresses, lastSeenMillis, running,
-          relayFlags, consensusWeight, countryCode, firstSeenMillis,
-          aSNumber, contact, familyFingerprints);
-      if (this.documentStore.store(summaryDocument, fingerprint)) {
-        this.writtenDocuments++;
-      };
-    }
-    Logger.printStatusTime("Wrote summary document files");
-  }
-
-  public String getStatsString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append("    " + Logger.formatDecimalNumber(this.writtenDocuments)
-        + " summary document files written\n");
-    sb.append("    " + Logger.formatDecimalNumber(this.deletedDocuments)
-        + " summary document files deleted\n");
-    return sb.toString();
-  }
-}
diff --git a/src/org/torproject/onionoo/Time.java b/src/org/torproject/onionoo/Time.java
deleted file mode 100644
index c969556..0000000
--- a/src/org/torproject/onionoo/Time.java
+++ /dev/null
@@ -1,14 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-/*
- * Wrapper for System.currentTimeMillis() that can be replaced with a
- * custom time source for testing.
- */
-public class Time {
-  public long currentTimeMillis() {
-    return System.currentTimeMillis();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/UpdateStatus.java b/src/org/torproject/onionoo/UpdateStatus.java
deleted file mode 100644
index a697cbf..0000000
--- a/src/org/torproject/onionoo/UpdateStatus.java
+++ /dev/null
@@ -1,7 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-public class UpdateStatus extends Document {
-}
-
diff --git a/src/org/torproject/onionoo/UptimeDocument.java b/src/org/torproject/onionoo/UptimeDocument.java
deleted file mode 100644
index 1946e2c..0000000
--- a/src/org/torproject/onionoo/UptimeDocument.java
+++ /dev/null
@@ -1,23 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Map;
-
-public class UptimeDocument extends Document {
-
-  @SuppressWarnings("unused")
-  private String fingerprint;
-  public void setFingerprint(String fingerprint) {
-    this.fingerprint = fingerprint;
-  }
-
-  private Map<String, GraphHistory> uptime;
-  public void setUptime(Map<String, GraphHistory> uptime) {
-    this.uptime = uptime;
-  }
-  public Map<String, GraphHistory> getUptime() {
-    return this.uptime;
-  }
-}
-
diff --git a/src/org/torproject/onionoo/UptimeDocumentWriter.java b/src/org/torproject/onionoo/UptimeDocumentWriter.java
deleted file mode 100644
index 8293f77..0000000
--- a/src/org/torproject/onionoo/UptimeDocumentWriter.java
+++ /dev/null
@@ -1,291 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-public class UptimeDocumentWriter implements FingerprintListener,
-    DocumentWriter {
-
-  private DescriptorSource descriptorSource;
-
-  private DocumentStore documentStore;
-
-  private long now;
-
-  public UptimeDocumentWriter() {
-    this.descriptorSource = ApplicationFactory.getDescriptorSource();
-    this.documentStore = ApplicationFactory.getDocumentStore();
-    this.now = ApplicationFactory.getTime().currentTimeMillis();
-    this.registerFingerprintListeners();
-  }
-
-  private void registerFingerprintListeners() {
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.RELAY_CONSENSUSES);
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.BRIDGE_STATUSES);
-  }
-
-  private SortedSet<String> newRelayFingerprints = new TreeSet<String>(),
-      newBridgeFingerprints = new TreeSet<String>();
-
-  public void processFingerprints(SortedSet<String> fingerprints,
-      boolean relay) {
-    if (relay) {
-      this.newRelayFingerprints.addAll(fingerprints);
-    } else {
-      this.newBridgeFingerprints.addAll(fingerprints);
-    }
-  }
-
-  public void writeDocuments() {
-    UptimeStatus uptimeStatus = this.documentStore.retrieve(
-        UptimeStatus.class, true);
-    if (uptimeStatus == null) {
-      return;
-    }
-    for (String fingerprint : this.newRelayFingerprints) {
-      this.updateDocument(true, fingerprint,
-          uptimeStatus.getRelayHistory());
-    }
-    for (String fingerprint : this.newBridgeFingerprints) {
-      this.updateDocument(false, fingerprint,
-          uptimeStatus.getBridgeHistory());
-    }
-    Logger.printStatusTime("Wrote uptime document files");
-  }
-
-  private int writtenDocuments = 0;
-
-  private void updateDocument(boolean relay, String fingerprint,
-      SortedSet<UptimeHistory> knownStatuses) {
-    UptimeStatus uptimeStatus = this.documentStore.retrieve(
-        UptimeStatus.class, true, fingerprint);
-    if (uptimeStatus != null) {
-      SortedSet<UptimeHistory> history = relay
-          ? uptimeStatus.getRelayHistory()
-          : uptimeStatus.getBridgeHistory();
-      UptimeDocument uptimeDocument = this.compileUptimeDocument(relay,
-          fingerprint, history, knownStatuses);
-      this.documentStore.store(uptimeDocument, fingerprint);
-      this.writtenDocuments++;
-    }
-  }
-
-  private String[] graphNames = new String[] {
-      "1_week",
-      "1_month",
-      "3_months",
-      "1_year",
-      "5_years" };
-
-  private long[] graphIntervals = new long[] {
-      DateTimeHelper.ONE_WEEK,
-      DateTimeHelper.ROUGHLY_ONE_MONTH,
-      DateTimeHelper.ROUGHLY_THREE_MONTHS,
-      DateTimeHelper.ROUGHLY_ONE_YEAR,
-      DateTimeHelper.ROUGHLY_FIVE_YEARS };
-
-  private long[] dataPointIntervals = new long[] {
-      DateTimeHelper.ONE_HOUR,
-      DateTimeHelper.FOUR_HOURS,
-      DateTimeHelper.TWELVE_HOURS,
-      DateTimeHelper.TWO_DAYS,
-      DateTimeHelper.TEN_DAYS };
-
-  private UptimeDocument compileUptimeDocument(boolean relay,
-      String fingerprint, SortedSet<UptimeHistory> history,
-      SortedSet<UptimeHistory> knownStatuses) {
-    UptimeDocument uptimeDocument = new UptimeDocument();
-    uptimeDocument.setFingerprint(fingerprint);
-    Map<String, GraphHistory> uptime =
-        new LinkedHashMap<String, GraphHistory>();
-    for (int graphIntervalIndex = 0; graphIntervalIndex <
-        this.graphIntervals.length; graphIntervalIndex++) {
-      String graphName = this.graphNames[graphIntervalIndex];
-      GraphHistory graphHistory = this.compileUptimeHistory(
-          graphIntervalIndex, relay, history, knownStatuses);
-      if (graphHistory != null) {
-        uptime.put(graphName, graphHistory);
-      }
-    }
-    uptimeDocument.setUptime(uptime);
-    return uptimeDocument;
-  }
-
-  private GraphHistory compileUptimeHistory(int graphIntervalIndex,
-      boolean relay, SortedSet<UptimeHistory> history,
-      SortedSet<UptimeHistory> knownStatuses) {
-    long graphInterval = this.graphIntervals[graphIntervalIndex];
-    long dataPointInterval =
-        this.dataPointIntervals[graphIntervalIndex];
-    int dataPointIntervalHours = (int) (dataPointInterval
-        / DateTimeHelper.ONE_HOUR);
-    List<Integer> uptimeDataPoints = new ArrayList<Integer>();
-    long intervalStartMillis = ((this.now - graphInterval)
-        / dataPointInterval) * dataPointInterval;
-    int uptimeHours = 0;
-    long firstStatusStartMillis = -1L;
-    for (UptimeHistory hist : history) {
-      if (hist.isRelay() != relay) {
-        continue;
-      }
-      if (firstStatusStartMillis < 0L) {
-        firstStatusStartMillis = hist.getStartMillis();
-      }
-      long histEndMillis = hist.getStartMillis() + DateTimeHelper.ONE_HOUR
-          * hist.getUptimeHours();
-      if (histEndMillis < intervalStartMillis) {
-        continue;
-      }
-      while (hist.getStartMillis() >= intervalStartMillis
-          + dataPointInterval) {
-        if (firstStatusStartMillis < intervalStartMillis
-            + dataPointInterval) {
-          uptimeDataPoints.add(uptimeHours);
-        } else {
-          uptimeDataPoints.add(-1);
-        }
-        uptimeHours = 0;
-        intervalStartMillis += dataPointInterval;
-      }
-      while (histEndMillis >= intervalStartMillis + dataPointInterval) {
-        uptimeHours += (int) ((intervalStartMillis + dataPointInterval
-            - Math.max(hist.getStartMillis(), intervalStartMillis))
-            / DateTimeHelper.ONE_HOUR);
-        uptimeDataPoints.add(uptimeHours);
-        uptimeHours = 0;
-        intervalStartMillis += dataPointInterval;
-      }
-      uptimeHours += (int) ((histEndMillis - Math.max(
-          hist.getStartMillis(), intervalStartMillis))
-          / DateTimeHelper.ONE_HOUR);
-    }
-    uptimeDataPoints.add(uptimeHours);
-    List<Integer> statusDataPoints = new ArrayList<Integer>();
-    intervalStartMillis = ((this.now - graphInterval)
-        / dataPointInterval) * dataPointInterval;
-    int statusHours = -1;
-    for (UptimeHistory hist : knownStatuses) {
-      if (hist.isRelay() != relay) {
-        continue;
-      }
-      long histEndMillis = hist.getStartMillis() + DateTimeHelper.ONE_HOUR
-          * hist.getUptimeHours();
-      if (histEndMillis < intervalStartMillis) {
-        continue;
-      }
-      while (hist.getStartMillis() >= intervalStartMillis
-          + dataPointInterval) {
-        statusDataPoints.add(statusHours * 5 > dataPointIntervalHours
-            ? statusHours : -1);
-        statusHours = -1;
-        intervalStartMillis += dataPointInterval;
-      }
-      while (histEndMillis >= intervalStartMillis + dataPointInterval) {
-        if (statusHours < 0) {
-          statusHours = 0;
-        }
-        statusHours += (int) ((intervalStartMillis + dataPointInterval
-            - Math.max(Math.max(hist.getStartMillis(),
-            firstStatusStartMillis), intervalStartMillis))
-            / DateTimeHelper.ONE_HOUR);
-        statusDataPoints.add(statusHours * 5 > dataPointIntervalHours
-            ? statusHours : -1);
-        statusHours = -1;
-        intervalStartMillis += dataPointInterval;
-      }
-      if (statusHours < 0) {
-        statusHours = 0;
-      }
-      statusHours += (int) ((histEndMillis - Math.max(Math.max(
-          hist.getStartMillis(), firstStatusStartMillis),
-          intervalStartMillis)) / DateTimeHelper.ONE_HOUR);
-    }
-    if (statusHours > 0) {
-      statusDataPoints.add(statusHours * 5 > dataPointIntervalHours
-          ? statusHours : -1);
-    }
-    List<Double> dataPoints = new ArrayList<Double>();
-    for (int dataPointIndex = 0; dataPointIndex < statusDataPoints.size();
-        dataPointIndex++) {
-      if (dataPointIndex >= uptimeDataPoints.size()) {
-        dataPoints.add(0.0);
-      } else if (uptimeDataPoints.get(dataPointIndex) >= 0 &&
-          statusDataPoints.get(dataPointIndex) > 0) {
-        dataPoints.add(((double) uptimeDataPoints.get(dataPointIndex))
-            / ((double) statusDataPoints.get(dataPointIndex)));
-      } else {
-        dataPoints.add(-1.0);
-      }
-    }
-    int firstNonNullIndex = -1, lastNonNullIndex = -1;
-    for (int dataPointIndex = 0; dataPointIndex < dataPoints.size();
-        dataPointIndex++) {
-      double dataPoint = dataPoints.get(dataPointIndex);
-      if (dataPoint >= 0.0) {
-        if (firstNonNullIndex < 0) {
-          firstNonNullIndex = dataPointIndex;
-        }
-        lastNonNullIndex = dataPointIndex;
-      }
-    }
-    if (firstNonNullIndex < 0) {
-      return null;
-    }
-    long firstDataPointMillis = (((this.now - graphInterval)
-        / dataPointInterval) + firstNonNullIndex)
-        * dataPointInterval + dataPointInterval / 2L;
-    if (graphIntervalIndex > 0 && firstDataPointMillis >=
-        this.now - graphIntervals[graphIntervalIndex - 1]) {
-      /* Skip uptime history object, because it doesn't contain
-       * anything new that wasn't already contained in the last
-       * uptime history object(s). */
-      return null;
-    }
-    long lastDataPointMillis = firstDataPointMillis
-        + (lastNonNullIndex - firstNonNullIndex) * dataPointInterval;
-    int count = lastNonNullIndex - firstNonNullIndex + 1;
-    GraphHistory graphHistory = new GraphHistory();
-    graphHistory.setFirst(DateTimeHelper.format(firstDataPointMillis));
-    graphHistory.setLast(DateTimeHelper.format(lastDataPointMillis));
-    graphHistory.setInterval((int) (dataPointInterval
-        / DateTimeHelper.ONE_SECOND));
-    graphHistory.setFactor(1.0 / 999.0);
-    graphHistory.setCount(count);
-    int previousNonNullIndex = -2;
-    boolean foundTwoAdjacentDataPoints = false;
-    List<Integer> values = new ArrayList<Integer>();
-    for (int dataPointIndex = firstNonNullIndex; dataPointIndex <=
-        lastNonNullIndex; dataPointIndex++) {
-      double dataPoint = dataPoints.get(dataPointIndex);
-      if (dataPoint >= 0.0) {
-        if (dataPointIndex - previousNonNullIndex == 1) {
-          foundTwoAdjacentDataPoints = true;
-        }
-        previousNonNullIndex = dataPointIndex;
-      }
-      values.add(dataPoint < -0.5 ? null : ((int) (dataPoint * 999.0)));
-    }
-    graphHistory.setValues(values);
-    if (foundTwoAdjacentDataPoints) {
-      return graphHistory;
-    } else {
-      return null;
-    }
-  }
-
-  public String getStatsString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append("    " + Logger.formatDecimalNumber(this.writtenDocuments)
-        + " uptime document files written\n");
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/UptimeStatus.java b/src/org/torproject/onionoo/UptimeStatus.java
deleted file mode 100644
index 92ca629..0000000
--- a/src/org/torproject/onionoo/UptimeStatus.java
+++ /dev/null
@@ -1,226 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Scanner;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-class UptimeHistory
-    implements Comparable<UptimeHistory> {
-
-  private boolean relay;
-  public boolean isRelay() {
-    return this.relay;
-  }
-
-  private long startMillis;
-  public long getStartMillis() {
-    return this.startMillis;
-  }
-
-  private int uptimeHours;
-  public int getUptimeHours() {
-    return this.uptimeHours;
-  }
-
-  UptimeHistory(boolean relay, long startMillis,
-      int uptimeHours) {
-    this.relay = relay;
-    this.startMillis = startMillis;
-    this.uptimeHours = uptimeHours;
-  }
-
-  public static UptimeHistory fromString(String uptimeHistoryString) {
-    String[] parts = uptimeHistoryString.split(" ", 3);
-    if (parts.length != 3) {
-      return null;
-    }
-    boolean relay = false;
-    if (parts[0].equals("r")) {
-      relay = true;
-    } else if (!parts[0].equals("b")) {
-      return null;
-    }
-    long startMillis = DateTimeHelper.parse(parts[1],
-          DateTimeHelper.DATEHOUR_NOSPACE_FORMAT);
-    if (startMillis < 0L) {
-      return null;
-    }
-    int uptimeHours = -1;
-    try {
-      uptimeHours = Integer.parseInt(parts[2]);
-    } catch (NumberFormatException e) {
-      return null;
-    }
-    return new UptimeHistory(relay, startMillis, uptimeHours);
-  }
-
-  public String toString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append(this.relay ? "r" : "b");
-    sb.append(" " + DateTimeHelper.format(this.startMillis,
-        DateTimeHelper.DATEHOUR_NOSPACE_FORMAT));
-    sb.append(" " + String.format("%d", this.uptimeHours));
-    return sb.toString();
-  }
-
-  public void addUptime(UptimeHistory other) {
-    this.uptimeHours += other.uptimeHours;
-    if (this.startMillis > other.startMillis) {
-      this.startMillis = other.startMillis;
-    }
-  }
-
-  public int compareTo(UptimeHistory other) {
-    if (this.relay && !other.relay) {
-      return -1;
-    } else if (!this.relay && other.relay) {
-      return 1;
-    }
-    return this.startMillis < other.startMillis ? -1 :
-        this.startMillis > other.startMillis ? 1 : 0;
-  }
-
-  public boolean equals(Object other) {
-    return other instanceof UptimeHistory &&
-        this.relay == ((UptimeHistory) other).relay &&
-        this.startMillis == ((UptimeHistory) other).startMillis;
-  }
-
-  public int hashCode() {
-    return (int) this.startMillis + (this.relay ? 1 : 0);
-  }
-}
-
-public class UptimeStatus extends Document {
-
-  private transient String fingerprint;
-
-  private transient boolean isDirty = false;
-
-  private SortedSet<UptimeHistory> relayHistory =
-      new TreeSet<UptimeHistory>();
-  public void setRelayHistory(SortedSet<UptimeHistory> relayHistory) {
-    this.relayHistory = relayHistory;
-  }
-  public SortedSet<UptimeHistory> getRelayHistory() {
-    return this.relayHistory;
-  }
-
-  private SortedSet<UptimeHistory> bridgeHistory =
-      new TreeSet<UptimeHistory>();
-  public void setBridgeHistory(SortedSet<UptimeHistory> bridgeHistory) {
-    this.bridgeHistory = bridgeHistory;
-  }
-  public SortedSet<UptimeHistory> getBridgeHistory() {
-    return this.bridgeHistory;
-  }
-
-  public static UptimeStatus loadOrCreate(String fingerprint) {
-    UptimeStatus uptimeStatus = (fingerprint == null) ?
-        ApplicationFactory.getDocumentStore().retrieve(
-            UptimeStatus.class, true) :
-        ApplicationFactory.getDocumentStore().retrieve(
-            UptimeStatus.class, true, fingerprint);
-    if (uptimeStatus == null) {
-      uptimeStatus = new UptimeStatus();
-    }
-    uptimeStatus.fingerprint = fingerprint;
-    return uptimeStatus;
-  }
-
-  public void fromDocumentString(String documentString) {
-    Scanner s = new Scanner(documentString);
-    while (s.hasNextLine()) {
-      String line = s.nextLine();
-      UptimeHistory parsedLine = UptimeHistory.fromString(line);
-      if (parsedLine != null) {
-        if (parsedLine.isRelay()) {
-          this.relayHistory.add(parsedLine);
-        } else {
-          this.bridgeHistory.add(parsedLine);
-        }
-      } else {
-        System.err.println("Could not parse uptime history line '"
-            + line + "'.  Skipping.");
-      }
-    }
-    s.close();
-  }
-
-  public void addToHistory(boolean relay, SortedSet<Long> newIntervals) {
-    for (long startMillis : newIntervals) {
-      SortedSet<UptimeHistory> history = relay ? this.relayHistory
-          : this.bridgeHistory;
-      UptimeHistory interval = new UptimeHistory(relay, startMillis, 1);
-      if (!history.headSet(interval).isEmpty()) {
-        UptimeHistory prev = history.headSet(interval).last();
-        if (prev.isRelay() == interval.isRelay() &&
-            prev.getStartMillis() + DateTimeHelper.ONE_HOUR
-            * prev.getUptimeHours() > interval.getStartMillis()) {
-          continue;
-        }
-      }
-      if (!history.tailSet(interval).isEmpty()) {
-        UptimeHistory next = history.tailSet(interval).first();
-        if (next.isRelay() == interval.isRelay() &&
-            next.getStartMillis() < interval.getStartMillis()
-            + DateTimeHelper.ONE_HOUR) {
-          continue;
-        }
-      }
-      history.add(interval);
-      this.isDirty = true;
-    }
-  }
-
-  public void storeIfChanged() {
-    if (this.isDirty) {
-      this.compressHistory(this.relayHistory);
-      this.compressHistory(this.bridgeHistory);
-      if (fingerprint == null) {
-        ApplicationFactory.getDocumentStore().store(this);
-      } else {
-        ApplicationFactory.getDocumentStore().store(this,
-            this.fingerprint);
-      }
-      this.isDirty = false;
-    }
-  }
-
-  private void compressHistory(SortedSet<UptimeHistory> history) {
-    SortedSet<UptimeHistory> uncompressedHistory =
-        new TreeSet<UptimeHistory>(history);
-    history.clear();
-    UptimeHistory lastInterval = null;
-    for (UptimeHistory interval : uncompressedHistory) {
-      if (lastInterval != null &&
-          lastInterval.getStartMillis() + DateTimeHelper.ONE_HOUR
-          * lastInterval.getUptimeHours() == interval.getStartMillis() &&
-          lastInterval.isRelay() == interval.isRelay()) {
-        lastInterval.addUptime(interval);
-      } else {
-        if (lastInterval != null) {
-          history.add(lastInterval);
-        }
-        lastInterval = interval;
-      }
-    }
-    if (lastInterval != null) {
-      history.add(lastInterval);
-    }
-  }
-
-  public String toDocumentString() {
-    StringBuilder sb = new StringBuilder();
-    for (UptimeHistory interval : this.relayHistory) {
-      sb.append(interval.toString() + "\n");
-    }
-    for (UptimeHistory interval : this.bridgeHistory) {
-      sb.append(interval.toString() + "\n");
-    }
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/UptimeStatusUpdater.java b/src/org/torproject/onionoo/UptimeStatusUpdater.java
deleted file mode 100644
index eccc2f2..0000000
--- a/src/org/torproject/onionoo/UptimeStatusUpdater.java
+++ /dev/null
@@ -1,126 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-import org.torproject.descriptor.BridgeNetworkStatus;
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.NetworkStatusEntry;
-import org.torproject.descriptor.RelayNetworkStatusConsensus;
-
-public class UptimeStatusUpdater implements DescriptorListener,
-    StatusUpdater {
-
-  private DescriptorSource descriptorSource;
-
-  public UptimeStatusUpdater() {
-    this.descriptorSource = ApplicationFactory.getDescriptorSource();
-    this.registerDescriptorListeners();
-  }
-
-  private void registerDescriptorListeners() {
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.RELAY_CONSENSUSES);
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.BRIDGE_STATUSES);
-  }
-
-  public void processDescriptor(Descriptor descriptor, boolean relay) {
-    if (descriptor instanceof RelayNetworkStatusConsensus) {
-      this.processRelayNetworkStatusConsensus(
-          (RelayNetworkStatusConsensus) descriptor);
-    } else if (descriptor instanceof BridgeNetworkStatus) {
-      this.processBridgeNetworkStatus(
-          (BridgeNetworkStatus) descriptor);
-    }
-  }
-
-  private SortedSet<Long> newRelayStatuses = new TreeSet<Long>(),
-      newBridgeStatuses = new TreeSet<Long>();
-  private SortedMap<String, SortedSet<Long>>
-      newRunningRelays = new TreeMap<String, SortedSet<Long>>(),
-      newRunningBridges = new TreeMap<String, SortedSet<Long>>();
-
-  private void processRelayNetworkStatusConsensus(
-      RelayNetworkStatusConsensus consensus) {
-    SortedSet<String> fingerprints = new TreeSet<String>();
-    for (NetworkStatusEntry entry :
-        consensus.getStatusEntries().values()) {
-      if (entry.getFlags().contains("Running")) {
-        fingerprints.add(entry.getFingerprint());
-      }
-    }
-    if (!fingerprints.isEmpty()) {
-      long dateHourMillis = (consensus.getValidAfterMillis()
-          / DateTimeHelper.ONE_HOUR) * DateTimeHelper.ONE_HOUR;
-      for (String fingerprint : fingerprints) {
-        if (!this.newRunningRelays.containsKey(fingerprint)) {
-          this.newRunningRelays.put(fingerprint, new TreeSet<Long>());
-        }
-        this.newRunningRelays.get(fingerprint).add(dateHourMillis);
-      }
-      this.newRelayStatuses.add(dateHourMillis);
-    }
-  }
-
-  private void processBridgeNetworkStatus(BridgeNetworkStatus status) {
-    SortedSet<String> fingerprints = new TreeSet<String>();
-    for (NetworkStatusEntry entry :
-        status.getStatusEntries().values()) {
-      if (entry.getFlags().contains("Running")) {
-        fingerprints.add(entry.getFingerprint());
-      }
-    }
-    if (!fingerprints.isEmpty()) {
-      long dateHourMillis = (status.getPublishedMillis()
-          / DateTimeHelper.ONE_HOUR) * DateTimeHelper.ONE_HOUR;
-      for (String fingerprint : fingerprints) {
-        if (!this.newRunningBridges.containsKey(fingerprint)) {
-          this.newRunningBridges.put(fingerprint, new TreeSet<Long>());
-        }
-        this.newRunningBridges.get(fingerprint).add(dateHourMillis);
-      }
-      this.newBridgeStatuses.add(dateHourMillis);
-    }
-  }
-
-  public void updateStatuses() {
-    for (Map.Entry<String, SortedSet<Long>> e :
-        this.newRunningRelays.entrySet()) {
-      this.updateStatus(true, e.getKey(), e.getValue());
-    }
-    this.updateStatus(true, null, this.newRelayStatuses);
-    for (Map.Entry<String, SortedSet<Long>> e :
-        this.newRunningBridges.entrySet()) {
-      this.updateStatus(false, e.getKey(), e.getValue());
-    }
-    this.updateStatus(false, null, this.newBridgeStatuses);
-  }
-
-  private void updateStatus(boolean relay, String fingerprint,
-      SortedSet<Long> newUptimeHours) {
-    UptimeStatus uptimeStatus = UptimeStatus.loadOrCreate(fingerprint);
-    uptimeStatus.addToHistory(relay, newUptimeHours);
-    uptimeStatus.storeIfChanged();
-  }
-
-  public String getStatsString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append("    " + Logger.formatDecimalNumber(
-            this.newRelayStatuses.size()) + " hours of relay uptimes "
-            + "processed\n");
-    sb.append("    " + Logger.formatDecimalNumber(
-        this.newBridgeStatuses.size()) + " hours of bridge uptimes "
-        + "processed\n");
-    sb.append("    " + Logger.formatDecimalNumber(
-        this.newRunningRelays.size() + this.newRunningBridges.size())
-        + " uptime status files updated\n");
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/WeightsDocument.java b/src/org/torproject/onionoo/WeightsDocument.java
deleted file mode 100644
index 4cd0021..0000000
--- a/src/org/torproject/onionoo/WeightsDocument.java
+++ /dev/null
@@ -1,64 +0,0 @@
-/* Copyright 2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Map;
-
-public class WeightsDocument extends Document {
-
-  @SuppressWarnings("unused")
-  private String fingerprint;
-  public void setFingerprint(String fingerprint) {
-    this.fingerprint = fingerprint;
-  }
-
-  @SuppressWarnings("unused")
-  private Map<String, GraphHistory> advertised_bandwidth_fraction;
-  public void setAdvertisedBandwidthFraction(
-      Map<String, GraphHistory> advertisedBandwidthFraction) {
-    this.advertised_bandwidth_fraction = advertisedBandwidthFraction;
-  }
-
-  @SuppressWarnings("unused")
-  private Map<String, GraphHistory> consensus_weight_fraction;
-  public void setConsensusWeightFraction(
-      Map<String, GraphHistory> consensusWeightFraction) {
-    this.consensus_weight_fraction = consensusWeightFraction;
-  }
-
-  @SuppressWarnings("unused")
-  private Map<String, GraphHistory> guard_probability;
-  public void setGuardProbability(
-      Map<String, GraphHistory> guardProbability) {
-    this.guard_probability = guardProbability;
-  }
-
-  @SuppressWarnings("unused")
-  private Map<String, GraphHistory> middle_probability;
-  public void setMiddleProbability(
-      Map<String, GraphHistory> middleProbability) {
-    this.middle_probability = middleProbability;
-  }
-
-  @SuppressWarnings("unused")
-  private Map<String, GraphHistory> exit_probability;
-  public void setExitProbability(
-      Map<String, GraphHistory> exitProbability) {
-    this.exit_probability = exitProbability;
-  }
-
-  @SuppressWarnings("unused")
-  private Map<String, GraphHistory> advertised_bandwidth;
-  public void setAdvertisedBandwidth(
-      Map<String, GraphHistory> advertisedBandwidth) {
-    this.advertised_bandwidth = advertisedBandwidth;
-  }
-
-  @SuppressWarnings("unused")
-  private Map<String, GraphHistory> consensus_weight;
-  public void setConsensusWeight(
-      Map<String, GraphHistory> consensusWeight) {
-    this.consensus_weight = consensusWeight;
-  }
-}
-
diff --git a/src/org/torproject/onionoo/WeightsDocumentWriter.java b/src/org/torproject/onionoo/WeightsDocumentWriter.java
deleted file mode 100644
index 2e0d465..0000000
--- a/src/org/torproject/onionoo/WeightsDocumentWriter.java
+++ /dev/null
@@ -1,222 +0,0 @@
-/* Copyright 2012--2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-
-public class WeightsDocumentWriter implements FingerprintListener,
-    DocumentWriter {
-
-  private DescriptorSource descriptorSource;
-
-  private DocumentStore documentStore;
-
-  private long now;
-
-  public WeightsDocumentWriter() {
-    this.descriptorSource = ApplicationFactory.getDescriptorSource();
-    this.documentStore = ApplicationFactory.getDocumentStore();
-    this.now = ApplicationFactory.getTime().currentTimeMillis();
-    this.registerFingerprintListeners();
-  }
-
-  private void registerFingerprintListeners() {
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.RELAY_CONSENSUSES);
-    this.descriptorSource.registerFingerprintListener(this,
-        DescriptorType.RELAY_SERVER_DESCRIPTORS);
-  }
-
-  private Set<String> updateWeightsDocuments = new HashSet<String>();
-
-  public void processFingerprints(SortedSet<String> fingerprints,
-      boolean relay) {
-    if (relay) {
-      this.updateWeightsDocuments.addAll(fingerprints);
-    }
-  }
-
-  public void writeDocuments() {
-    this.writeWeightsDataFiles();
-    Logger.printStatusTime("Wrote weights document files");
-  }
-
-  private void writeWeightsDataFiles() {
-    for (String fingerprint : this.updateWeightsDocuments) {
-      WeightsStatus weightsStatus = this.documentStore.retrieve(
-          WeightsStatus.class, true, fingerprint);
-      if (weightsStatus == null) {
-        continue;
-      }
-      SortedMap<long[], double[]> history = weightsStatus.getHistory();
-      WeightsDocument weightsDocument = this.compileWeightsDocument(
-          fingerprint, history);
-      this.documentStore.store(weightsDocument, fingerprint);
-    }
-    Logger.printStatusTime("Wrote weights document files");
-  }
-
-  private String[] graphNames = new String[] {
-      "1_week",
-      "1_month",
-      "3_months",
-      "1_year",
-      "5_years" };
-
-  private long[] graphIntervals = new long[] {
-      DateTimeHelper.ONE_WEEK,
-      DateTimeHelper.ROUGHLY_ONE_MONTH,
-      DateTimeHelper.ROUGHLY_THREE_MONTHS,
-      DateTimeHelper.ROUGHLY_ONE_YEAR,
-      DateTimeHelper.ROUGHLY_FIVE_YEARS };
-
-  private long[] dataPointIntervals = new long[] {
-      DateTimeHelper.ONE_HOUR,
-      DateTimeHelper.FOUR_HOURS,
-      DateTimeHelper.TWELVE_HOURS,
-      DateTimeHelper.TWO_DAYS,
-      DateTimeHelper.TEN_DAYS };
-
-  private WeightsDocument compileWeightsDocument(String fingerprint,
-      SortedMap<long[], double[]> history) {
-    WeightsDocument weightsDocument = new WeightsDocument();
-    weightsDocument.setFingerprint(fingerprint);
-    weightsDocument.setAdvertisedBandwidthFraction(
-        this.compileGraphType(history, 0));
-    weightsDocument.setConsensusWeightFraction(
-        this.compileGraphType(history, 1));
-    weightsDocument.setGuardProbability(
-        this.compileGraphType(history, 2));
-    weightsDocument.setMiddleProbability(
-        this.compileGraphType(history, 3));
-    weightsDocument.setExitProbability(
-        this.compileGraphType(history, 4));
-    weightsDocument.setAdvertisedBandwidth(
-        this.compileGraphType(history, 5));
-    weightsDocument.setConsensusWeight(
-        this.compileGraphType(history, 6));
-    return weightsDocument;
-  }
-
-  private Map<String, GraphHistory> compileGraphType(
-      SortedMap<long[], double[]> history, int graphTypeIndex) {
-    Map<String, GraphHistory> graphs =
-        new LinkedHashMap<String, GraphHistory>();
-    for (int graphIntervalIndex = 0; graphIntervalIndex <
-        this.graphIntervals.length; graphIntervalIndex++) {
-      String graphName = this.graphNames[graphIntervalIndex];
-      GraphHistory graphHistory = this.compileWeightsHistory(
-          graphTypeIndex, graphIntervalIndex, history);
-      if (graphHistory != null) {
-        graphs.put(graphName, graphHistory);
-      }
-    }
-    return graphs;
-  }
-
-  private GraphHistory compileWeightsHistory(int graphTypeIndex,
-      int graphIntervalIndex, SortedMap<long[], double[]> history) {
-    long graphInterval = this.graphIntervals[graphIntervalIndex];
-    long dataPointInterval =
-        this.dataPointIntervals[graphIntervalIndex];
-    List<Double> dataPoints = new ArrayList<Double>();
-    long intervalStartMillis = ((this.now - graphInterval)
-        / dataPointInterval) * dataPointInterval;
-    long totalMillis = 0L;
-    double totalWeightTimesMillis = 0.0;
-    for (Map.Entry<long[], double[]> e : history.entrySet()) {
-      long startMillis = e.getKey()[0], endMillis = e.getKey()[1];
-      double weight = e.getValue()[graphTypeIndex];
-      if (endMillis < intervalStartMillis) {
-        continue;
-      }
-      while ((intervalStartMillis / dataPointInterval) !=
-          (endMillis / dataPointInterval)) {
-        dataPoints.add(totalMillis * 5L < dataPointInterval
-            ? -1.0 : totalWeightTimesMillis / (double) totalMillis);
-        totalWeightTimesMillis = 0.0;
-        totalMillis = 0L;
-        intervalStartMillis += dataPointInterval;
-      }
-      if (weight >= 0.0) {
-        totalWeightTimesMillis += weight
-            * ((double) (endMillis - startMillis));
-        totalMillis += (endMillis - startMillis);
-      }
-    }
-    dataPoints.add(totalMillis * 5L < dataPointInterval
-        ? -1.0 : totalWeightTimesMillis / (double) totalMillis);
-    double maxValue = 0.0;
-    int firstNonNullIndex = -1, lastNonNullIndex = -1;
-    for (int dataPointIndex = 0; dataPointIndex < dataPoints.size();
-        dataPointIndex++) {
-      double dataPoint = dataPoints.get(dataPointIndex);
-      if (dataPoint >= 0.0) {
-        if (firstNonNullIndex < 0) {
-          firstNonNullIndex = dataPointIndex;
-        }
-        lastNonNullIndex = dataPointIndex;
-        if (dataPoint > maxValue) {
-          maxValue = dataPoint;
-        }
-      }
-    }
-    if (firstNonNullIndex < 0) {
-      return null;
-    }
-    long firstDataPointMillis = (((this.now - graphInterval)
-        / dataPointInterval) + firstNonNullIndex) * dataPointInterval
-        + dataPointInterval / 2L;
-    if (graphIntervalIndex > 0 && firstDataPointMillis >=
-        this.now - graphIntervals[graphIntervalIndex - 1]) {
-      /* Skip weights history object, because it doesn't contain
-       * anything new that wasn't already contained in the last
-       * weights history object(s). */
-      return null;
-    }
-    long lastDataPointMillis = firstDataPointMillis
-        + (lastNonNullIndex - firstNonNullIndex) * dataPointInterval;
-    double factor = ((double) maxValue) / 999.0;
-    int count = lastNonNullIndex - firstNonNullIndex + 1;
-    GraphHistory graphHistory = new GraphHistory();
-    graphHistory.setFirst(DateTimeHelper.format(firstDataPointMillis));
-    graphHistory.setLast(DateTimeHelper.format(lastDataPointMillis));
-    graphHistory.setInterval((int) (dataPointInterval
-        / DateTimeHelper.ONE_SECOND));
-    graphHistory.setFactor(factor);
-    graphHistory.setCount(count);
-    int previousNonNullIndex = -2;
-    boolean foundTwoAdjacentDataPoints = false;
-    List<Integer> values = new ArrayList<Integer>();
-    for (int dataPointIndex = firstNonNullIndex; dataPointIndex <=
-        lastNonNullIndex; dataPointIndex++) {
-      double dataPoint = dataPoints.get(dataPointIndex);
-      if (dataPoint >= 0.0) {
-        if (dataPointIndex - previousNonNullIndex == 1) {
-          foundTwoAdjacentDataPoints = true;
-        }
-        previousNonNullIndex = dataPointIndex;
-      }
-      values.add(dataPoint < 0.0 ? null :
-          (int) ((dataPoint * 999.0) / maxValue));
-    }
-    graphHistory.setValues(values);
-    if (foundTwoAdjacentDataPoints) {
-      return graphHistory;
-    } else {
-      return null;
-    }
-  }
-
-  public String getStatsString() {
-    /* TODO Add statistics string. */
-    return null;
-  }
-}
diff --git a/src/org/torproject/onionoo/WeightsStatus.java b/src/org/torproject/onionoo/WeightsStatus.java
deleted file mode 100644
index 6d06ca4..0000000
--- a/src/org/torproject/onionoo/WeightsStatus.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package org.torproject.onionoo;
-
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.SortedMap;
-import java.util.TreeMap;
-
-public class WeightsStatus extends Document {
-
-  private SortedMap<long[], double[]> history =
-      new TreeMap<long[], double[]>(new Comparator<long[]>() {
-    public int compare(long[] a, long[] b) {
-      return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
-    }
-  });
-  public void setHistory(SortedMap<long[], double[]> history) {
-    this.history = history;
-  }
-  public SortedMap<long[], double[]> getHistory() {
-    return this.history;
-  }
-
-  private Map<String, Integer> advertisedBandwidths =
-      new HashMap<String, Integer>();
-  public Map<String, Integer> getAdvertisedBandwidths() {
-    return this.advertisedBandwidths;
-  }
-
-  public void fromDocumentString(String documentString) {
-    Scanner s = new Scanner(documentString);
-    while (s.hasNextLine()) {
-      String line = s.nextLine();
-      String[] parts = line.split(" ");
-      if (parts.length == 2) {
-        String descriptorDigest = parts[0];
-        int advertisedBandwidth = Integer.parseInt(parts[1]);
-        this.advertisedBandwidths.put(descriptorDigest,
-            advertisedBandwidth);
-        continue;
-      }
-      if (parts.length != 9 && parts.length != 11) {
-        System.err.println("Illegal line '" + line + "' in weights "
-              + "status file.  Skipping this line.");
-        continue;
-      }
-      if (parts[4].equals("NaN")) {
-        /* Remove corrupt lines written on 2013-07-07 and the days
-         * after. */
-        continue;
-      }
-      long validAfterMillis = DateTimeHelper.parse(parts[0] + " "
-          + parts[1]);
-      long freshUntilMillis = DateTimeHelper.parse(parts[2] + " "
-          + parts[3]);
-      if (validAfterMillis < 0L || freshUntilMillis < 0L) {
-        System.err.println("Could not parse timestamp while reading "
-            + "weights status file.  Skipping.");
-        break;
-      }
-      long[] interval = new long[] { validAfterMillis, freshUntilMillis };
-      double[] weights = new double[] {
-          Double.parseDouble(parts[4]),
-          Double.parseDouble(parts[5]),
-          Double.parseDouble(parts[6]),
-          Double.parseDouble(parts[7]),
-          Double.parseDouble(parts[8]), -1.0, -1.0 };
-      if (parts.length == 11) {
-        weights[5] = Double.parseDouble(parts[9]);
-        weights[6] = Double.parseDouble(parts[10]);
-      }
-      this.history.put(interval, weights);
-    }
-    s.close();
-  }
-
-  public String toDocumentString() {
-    StringBuilder sb = new StringBuilder();
-    for (Map.Entry<String, Integer> e :
-        this.advertisedBandwidths.entrySet()) {
-      sb.append(e.getKey() + " " + String.valueOf(e.getValue()) + "\n");
-    }
-    for (Map.Entry<long[], double[]> e : history.entrySet()) {
-      long[] fresh = e.getKey();
-      double[] weights = e.getValue();
-      sb.append(DateTimeHelper.format(fresh[0]) + " "
-          + DateTimeHelper.format(fresh[1]));
-      for (double weight : weights) {
-        sb.append(String.format(" %.12f", weight));
-      }
-      sb.append("\n");
-    }
-    return sb.toString();
-  }
-}
-
diff --git a/src/org/torproject/onionoo/WeightsStatusUpdater.java b/src/org/torproject/onionoo/WeightsStatusUpdater.java
deleted file mode 100644
index 5932da6..0000000
--- a/src/org/torproject/onionoo/WeightsStatusUpdater.java
+++ /dev/null
@@ -1,328 +0,0 @@
-/* Copyright 2012--2014 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.onionoo;
-
-import java.util.Arrays;
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.NetworkStatusEntry;
-import org.torproject.descriptor.RelayNetworkStatusConsensus;
-import org.torproject.descriptor.ServerDescriptor;
-
-public class WeightsStatusUpdater implements DescriptorListener,
-    StatusUpdater {
-
-  private DescriptorSource descriptorSource;
-
-  private DocumentStore documentStore;
-
-  private long now;
-
-  public WeightsStatusUpdater() {
-    this.descriptorSource = ApplicationFactory.getDescriptorSource();
-    this.documentStore = ApplicationFactory.getDocumentStore();
-    this.now = ApplicationFactory.getTime().currentTimeMillis();
-    this.registerDescriptorListeners();
-  }
-
-  private void registerDescriptorListeners() {
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.RELAY_CONSENSUSES);
-    this.descriptorSource.registerDescriptorListener(this,
-        DescriptorType.RELAY_SERVER_DESCRIPTORS);
-  }
-
-  public void processDescriptor(Descriptor descriptor, boolean relay) {
-    if (descriptor instanceof ServerDescriptor) {
-      this.processRelayServerDescriptor((ServerDescriptor) descriptor);
-    } else if (descriptor instanceof RelayNetworkStatusConsensus) {
-      this.processRelayNetworkConsensus(
-          (RelayNetworkStatusConsensus) descriptor);
-    }
-  }
-
-  public void updateStatuses() {
-    /* Nothing to do. */
-  }
-
-  private void processRelayNetworkConsensus(
-      RelayNetworkStatusConsensus consensus) {
-    long validAfterMillis = consensus.getValidAfterMillis(),
-        freshUntilMillis = consensus.getFreshUntilMillis();
-    SortedMap<String, double[]> pathSelectionWeights =
-        this.calculatePathSelectionProbabilities(consensus);
-    this.updateWeightsHistory(validAfterMillis, freshUntilMillis,
-        pathSelectionWeights);
-  }
-
-  private void processRelayServerDescriptor(
-      ServerDescriptor serverDescriptor) {
-    String digest = serverDescriptor.getServerDescriptorDigest().
-        toUpperCase();
-    int advertisedBandwidth = Math.min(Math.min(
-        serverDescriptor.getBandwidthBurst(),
-        serverDescriptor.getBandwidthObserved()),
-        serverDescriptor.getBandwidthRate());
-    String fingerprint = serverDescriptor.getFingerprint();
-    WeightsStatus weightsStatus = this.documentStore.retrieve(
-        WeightsStatus.class, true, fingerprint);
-    if (weightsStatus == null) {
-      weightsStatus = new WeightsStatus();
-    }
-    weightsStatus.getAdvertisedBandwidths().put(digest,
-        advertisedBandwidth);
-    this.documentStore.store(weightsStatus, fingerprint);
-}
-
-  private void updateWeightsHistory(long validAfterMillis,
-      long freshUntilMillis,
-      SortedMap<String, double[]> pathSelectionWeights) {
-    String fingerprint = null;
-    double[] weights = null;
-    do {
-      fingerprint = null;
-      synchronized (pathSelectionWeights) {
-        if (!pathSelectionWeights.isEmpty()) {
-          fingerprint = pathSelectionWeights.firstKey();
-          weights = pathSelectionWeights.remove(fingerprint);
-        }
-      }
-      if (fingerprint != null) {
-        this.addToHistory(fingerprint, validAfterMillis,
-            freshUntilMillis, weights);
-      }
-    } while (fingerprint != null);
-  }
-
-  private SortedMap<String, double[]> calculatePathSelectionProbabilities(
-      RelayNetworkStatusConsensus consensus) {
-    boolean containsBandwidthWeights = false;
-    double wgg = 1.0, wgd = 1.0, wmg = 1.0, wmm = 1.0, wme = 1.0,
-        wmd = 1.0, wee = 1.0, wed = 1.0;
-    SortedMap<String, Integer> bandwidthWeights =
-        consensus.getBandwidthWeights();
-    if (bandwidthWeights != null) {
-      SortedSet<String> missingWeightKeys = new TreeSet<String>(
-          Arrays.asList("Wgg,Wgd,Wmg,Wmm,Wme,Wmd,Wee,Wed".split(",")));
-      missingWeightKeys.removeAll(bandwidthWeights.keySet());
-      if (missingWeightKeys.isEmpty()) {
-        wgg = ((double) bandwidthWeights.get("Wgg")) / 10000.0;
-        wgd = ((double) bandwidthWeights.get("Wgd")) / 10000.0;
-        wmg = ((double) bandwidthWeights.get("Wmg")) / 10000.0;
-        wmm = ((double) bandwidthWeights.get("Wmm")) / 10000.0;
-        wme = ((double) bandwidthWeights.get("Wme")) / 10000.0;
-        wmd = ((double) bandwidthWeights.get("Wmd")) / 10000.0;
-        wee = ((double) bandwidthWeights.get("Wee")) / 10000.0;
-        wed = ((double) bandwidthWeights.get("Wed")) / 10000.0;
-        containsBandwidthWeights = true;
-      }
-    }
-    SortedMap<String, Double>
-        advertisedBandwidths = new TreeMap<String, Double>(),
-        consensusWeights = new TreeMap<String, Double>(),
-        guardWeights = new TreeMap<String, Double>(),
-        middleWeights = new TreeMap<String, Double>(),
-        exitWeights = new TreeMap<String, Double>();
-    double totalAdvertisedBandwidth = 0.0;
-    double totalConsensusWeight = 0.0;
-    double totalGuardWeight = 0.0;
-    double totalMiddleWeight = 0.0;
-    double totalExitWeight = 0.0;
-    for (NetworkStatusEntry relay :
-        consensus.getStatusEntries().values()) {
-      String fingerprint = relay.getFingerprint();
-      if (!relay.getFlags().contains("Running")) {
-        continue;
-      }
-      String digest = relay.getDescriptor().toUpperCase();
-      WeightsStatus weightsStatus = this.documentStore.retrieve(
-          WeightsStatus.class, true, fingerprint);
-      if (weightsStatus != null &&
-          weightsStatus.getAdvertisedBandwidths() != null &&
-          weightsStatus.getAdvertisedBandwidths().containsKey(digest)) {
-        /* Read advertised bandwidth from weights status file.  Server
-         * descriptors are parsed before consensuses, so we're sure that
-         * if there's a server descriptor for this relay, it'll be
-         * contained in the weights status file by now. */
-        double advertisedBandwidth =
-            (double) weightsStatus.getAdvertisedBandwidths().get(digest);
-        advertisedBandwidths.put(fingerprint, advertisedBandwidth);
-        totalAdvertisedBandwidth += advertisedBandwidth;
-      }
-      if (relay.getBandwidth() >= 0L) {
-        double consensusWeight = (double) relay.getBandwidth();
-        consensusWeights.put(fingerprint, consensusWeight);
-        totalConsensusWeight += consensusWeight;
-        if (containsBandwidthWeights) {
-          double guardWeight = (double) relay.getBandwidth();
-          double middleWeight = (double) relay.getBandwidth();
-          double exitWeight = (double) relay.getBandwidth();
-          boolean isExit = relay.getFlags().contains("Exit") &&
-              !relay.getFlags().contains("BadExit");
-          boolean isGuard = relay.getFlags().contains("Guard");
-          if (isGuard && isExit) {
-            guardWeight *= wgd;
-            middleWeight *= wmd;
-            exitWeight *= wed;
-          } else if (isGuard) {
-            guardWeight *= wgg;
-            middleWeight *= wmg;
-            exitWeight = 0.0;
-          } else if (isExit) {
-            guardWeight = 0.0;
-            middleWeight *= wme;
-            exitWeight *= wee;
-          } else {
-            guardWeight = 0.0;
-            middleWeight *= wmm;
-            exitWeight = 0.0;
-          }
-          guardWeights.put(fingerprint, guardWeight);
-          middleWeights.put(fingerprint, middleWeight);
-          exitWeights.put(fingerprint, exitWeight);
-          totalGuardWeight += guardWeight;
-          totalMiddleWeight += middleWeight;
-          totalExitWeight += exitWeight;
-        }
-      }
-    }
-    SortedMap<String, double[]> pathSelectionProbabilities =
-        new TreeMap<String, double[]>();
-    SortedSet<String> fingerprints = new TreeSet<String>();
-    fingerprints.addAll(consensusWeights.keySet());
-    fingerprints.addAll(advertisedBandwidths.keySet());
-    for (String fingerprint : fingerprints) {
-      double[] probabilities = new double[] { -1.0, -1.0, -1.0, -1.0,
-          -1.0, -1.0, -1.0 };
-      if (consensusWeights.containsKey(fingerprint) &&
-          totalConsensusWeight > 0.0) {
-        probabilities[1] = consensusWeights.get(fingerprint) /
-            totalConsensusWeight;
-        probabilities[6] = consensusWeights.get(fingerprint);
-      }
-      if (guardWeights.containsKey(fingerprint) &&
-          totalGuardWeight > 0.0) {
-        probabilities[2] = guardWeights.get(fingerprint) /
-            totalGuardWeight;
-      }
-      if (middleWeights.containsKey(fingerprint) &&
-          totalMiddleWeight > 0.0) {
-        probabilities[3] = middleWeights.get(fingerprint) /
-            totalMiddleWeight;
-      }
-      if (exitWeights.containsKey(fingerprint) &&
-          totalExitWeight > 0.0) {
-        probabilities[4] = exitWeights.get(fingerprint) /
-            totalExitWeight;
-      }
-      if (advertisedBandwidths.containsKey(fingerprint) &&
-          totalAdvertisedBandwidth > 0.0) {
-        probabilities[0] = advertisedBandwidths.get(fingerprint)
-            / totalAdvertisedBandwidth;
-        probabilities[5] = advertisedBandwidths.get(fingerprint);
-      }
-      pathSelectionProbabilities.put(fingerprint, probabilities);
-    }
-    return pathSelectionProbabilities;
-  }
-
-  private void addToHistory(String fingerprint, long validAfterMillis,
-      long freshUntilMillis, double[] weights) {
-    WeightsStatus weightsStatus = this.documentStore.retrieve(
-        WeightsStatus.class, true, fingerprint);
-    if (weightsStatus == null) {
-      weightsStatus = new WeightsStatus();
-    }
-    SortedMap<long[], double[]> history = weightsStatus.getHistory();
-    long[] interval = new long[] { validAfterMillis, freshUntilMillis };
-    if ((history.headMap(interval).isEmpty() ||
-        history.headMap(interval).lastKey()[1] <= validAfterMillis) &&
-        (history.tailMap(interval).isEmpty() ||
-        history.tailMap(interval).firstKey()[0] >= freshUntilMillis)) {
-      history.put(interval, weights);
-      this.compressHistory(weightsStatus);
-      this.documentStore.store(weightsStatus, fingerprint);
-    }
-  }
-
-  private void compressHistory(WeightsStatus weightsStatus) {
-    SortedMap<long[], double[]> history = weightsStatus.getHistory();
-    SortedMap<long[], double[]> compressedHistory =
-        new TreeMap<long[], double[]>(history.comparator());
-    long lastStartMillis = 0L, lastEndMillis = 0L;
-    double[] lastWeights = null;
-    String lastMonthString = "1970-01";
-    int lastMissingValues = -1;
-    for (Map.Entry<long[], double[]> e : history.entrySet()) {
-      long startMillis = e.getKey()[0], endMillis = e.getKey()[1];
-      double[] weights = e.getValue();
-      long intervalLengthMillis;
-      if (this.now - endMillis <= DateTimeHelper.ONE_WEEK) {
-        intervalLengthMillis = DateTimeHelper.ONE_HOUR;
-      } else if (this.now - endMillis <=
-          DateTimeHelper.ROUGHLY_ONE_MONTH) {
-        intervalLengthMillis = DateTimeHelper.FOUR_HOURS;
-      } else if (this.now - endMillis <=
-          DateTimeHelper.ROUGHLY_THREE_MONTHS) {
-        intervalLengthMillis = DateTimeHelper.TWELVE_HOURS;
-      } else if (this.now - endMillis <=
-          DateTimeHelper.ROUGHLY_ONE_YEAR) {
-        intervalLengthMillis = DateTimeHelper.TWO_DAYS;
-      } else {
-        intervalLengthMillis = DateTimeHelper.TEN_DAYS;
-      }
-      String monthString = DateTimeHelper.format(startMillis,
-          DateTimeHelper.ISO_YEARMONTH_FORMAT);
-      int missingValues = 0;
-      for (int i = 0; i < weights.length; i++) {
-        if (weights[i] < -0.5) {
-          missingValues += 1 << i;
-        }
-      }
-      if (lastEndMillis == startMillis &&
-          ((lastEndMillis - 1L) / intervalLengthMillis) ==
-          ((endMillis - 1L) / intervalLengthMillis) &&
-          lastMonthString.equals(monthString) &&
-          lastMissingValues == missingValues) {
-        double lastIntervalInHours = (double) ((lastEndMillis
-            - lastStartMillis) / DateTimeHelper.ONE_HOUR);
-        double currentIntervalInHours = (double) ((endMillis
-            - startMillis) / DateTimeHelper.ONE_HOUR);
-        double newIntervalInHours = (double) ((endMillis
-            - lastStartMillis) / DateTimeHelper.ONE_HOUR);
-        for (int i = 0; i < lastWeights.length; i++) {
-          lastWeights[i] *= lastIntervalInHours;
-          lastWeights[i] += weights[i] * currentIntervalInHours;
-          lastWeights[i] /= newIntervalInHours;
-        }
-        lastEndMillis = endMillis;
-      } else {
-        if (lastStartMillis > 0L) {
-          compressedHistory.put(new long[] { lastStartMillis,
-              lastEndMillis }, lastWeights);
-        }
-        lastStartMillis = startMillis;
-        lastEndMillis = endMillis;
-        lastWeights = weights;
-      }
-      lastMonthString = monthString;
-      lastMissingValues = missingValues;
-    }
-    if (lastStartMillis > 0L) {
-      compressedHistory.put(new long[] { lastStartMillis, lastEndMillis },
-          lastWeights);
-    }
-    weightsStatus.setHistory(compressedHistory);
-  }
-
-  public String getStatsString() {
-    /* TODO Add statistics string. */
-    return null;
-  }
-}
-
diff --git a/src/org/torproject/onionoo/cron/Main.java b/src/org/torproject/onionoo/cron/Main.java
new file mode 100644
index 0000000..94dfa00
--- /dev/null
+++ b/src/org/torproject/onionoo/cron/Main.java
@@ -0,0 +1,140 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.cron;
+
+import java.io.File;
+
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.updater.BandwidthStatusUpdater;
+import org.torproject.onionoo.updater.ClientsStatusUpdater;
+import org.torproject.onionoo.updater.DescriptorSource;
+import org.torproject.onionoo.updater.LookupService;
+import org.torproject.onionoo.updater.NodeDetailsStatusUpdater;
+import org.torproject.onionoo.updater.ReverseDomainNameResolver;
+import org.torproject.onionoo.updater.StatusUpdater;
+import org.torproject.onionoo.updater.UptimeStatusUpdater;
+import org.torproject.onionoo.updater.WeightsStatusUpdater;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.LockFile;
+import org.torproject.onionoo.util.Logger;
+import org.torproject.onionoo.writer.BandwidthDocumentWriter;
+import org.torproject.onionoo.writer.ClientsDocumentWriter;
+import org.torproject.onionoo.writer.DetailsDocumentWriter;
+import org.torproject.onionoo.writer.DocumentWriter;
+import org.torproject.onionoo.writer.SummaryDocumentWriter;
+import org.torproject.onionoo.writer.UptimeDocumentWriter;
+import org.torproject.onionoo.writer.WeightsDocumentWriter;
+
+/* Update search data and status data files. */
+public class Main {
+
+  private Main() {
+  }
+
+  public static void main(String[] args) {
+
+    LockFile lf = new LockFile();
+    Logger.setTime();
+    Logger.printStatus("Initializing.");
+    if (lf.acquireLock()) {
+      Logger.printStatusTime("Acquired lock");
+    } else {
+      Logger.printErrorTime("Could not acquire lock.  Is Onionoo "
+          + "already running?  Terminating");
+      return;
+    }
+
+    DescriptorSource dso = ApplicationFactory.getDescriptorSource();
+    Logger.printStatusTime("Initialized descriptor source");
+    DocumentStore ds = ApplicationFactory.getDocumentStore();
+    Logger.printStatusTime("Initialized document store");
+    LookupService ls = new LookupService(new File("geoip"));
+    Logger.printStatusTime("Initialized Geoip lookup service");
+    ReverseDomainNameResolver rdnr = new ReverseDomainNameResolver();
+    Logger.printStatusTime("Initialized reverse domain name resolver");
+    NodeDetailsStatusUpdater ndsu = new NodeDetailsStatusUpdater(rdnr,
+        ls);
+    Logger.printStatusTime("Initialized node data writer");
+    BandwidthStatusUpdater bsu = new BandwidthStatusUpdater();
+    Logger.printStatusTime("Initialized bandwidth status updater");
+    WeightsStatusUpdater wsu = new WeightsStatusUpdater();
+    Logger.printStatusTime("Initialized weights status updater");
+    ClientsStatusUpdater csu = new ClientsStatusUpdater();
+    Logger.printStatusTime("Initialized clients status updater");
+    UptimeStatusUpdater usu = new UptimeStatusUpdater();
+    Logger.printStatusTime("Initialized uptime status updater");
+    StatusUpdater[] sus = new StatusUpdater[] { ndsu, bsu, wsu, csu,
+        usu };
+
+    SummaryDocumentWriter sdw = new SummaryDocumentWriter();
+    Logger.printStatusTime("Initialized summary document writer");
+    DetailsDocumentWriter ddw = new DetailsDocumentWriter();
+    Logger.printStatusTime("Initialized details document writer");
+    BandwidthDocumentWriter bdw = new BandwidthDocumentWriter();
+    Logger.printStatusTime("Initialized bandwidth document writer");
+    WeightsDocumentWriter wdw = new WeightsDocumentWriter();
+    Logger.printStatusTime("Initialized weights document writer");
+    ClientsDocumentWriter cdw = new ClientsDocumentWriter();
+    Logger.printStatusTime("Initialized clients document writer");
+    UptimeDocumentWriter udw = new UptimeDocumentWriter();
+    Logger.printStatusTime("Initialized uptime document writer");
+    DocumentWriter[] dws = new DocumentWriter[] { sdw, ddw, bdw, wdw, cdw,
+        udw };
+
+    Logger.printStatus("Downloading descriptors.");
+    dso.downloadDescriptors();
+
+    Logger.printStatus("Reading descriptors.");
+    dso.readDescriptors();
+
+    Logger.printStatus("Updating internal status files.");
+    for (StatusUpdater su : sus) {
+      su.updateStatuses();
+      Logger.printStatusTime(su.getClass().getSimpleName()
+          + " updated status files");
+    }
+
+    Logger.printStatus("Updating document files.");
+    for (DocumentWriter dw : dws) {
+      dw.writeDocuments();
+    }
+
+    Logger.printStatus("Shutting down.");
+    dso.writeHistoryFiles();
+    Logger.printStatusTime("Wrote parse histories");
+    ds.flushDocumentCache();
+    Logger.printStatusTime("Flushed document cache");
+
+    Logger.printStatus("Gathering statistics.");
+    for (StatusUpdater su : sus) {
+      String statsString = su.getStatsString();
+      if (statsString != null) {
+        Logger.printStatistics(su.getClass().getSimpleName(),
+            statsString);
+      }
+    }
+    for (DocumentWriter dw : dws) {
+      String statsString = dw.getStatsString();
+      if (statsString != null) {
+        Logger.printStatistics(dw.getClass().getSimpleName(),
+            statsString);
+      }
+    }
+    Logger.printStatistics("Descriptor source", dso.getStatsString());
+    Logger.printStatistics("Document store", ds.getStatsString());
+    Logger.printStatistics("GeoIP lookup service", ls.getStatsString());
+    Logger.printStatistics("Reverse domain name resolver",
+        rdnr.getStatsString());
+
+    Logger.printStatus("Releasing lock.");
+    if (lf.releaseLock()) {
+      Logger.printStatusTime("Released lock");
+    } else {
+      Logger.printErrorTime("Could not release lock.  The next "
+          + "execution may not start as expected");
+    }
+
+    Logger.printStatus("Terminating.");
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/BandwidthDocument.java b/src/org/torproject/onionoo/docs/BandwidthDocument.java
new file mode 100644
index 0000000..ea20a5e
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/BandwidthDocument.java
@@ -0,0 +1,27 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.Map;
+
+public class BandwidthDocument extends Document {
+
+  @SuppressWarnings("unused")
+  private String fingerprint;
+  public void setFingerprint(String fingerprint) {
+    this.fingerprint = fingerprint;
+  }
+
+  @SuppressWarnings("unused")
+  private Map<String, GraphHistory> write_history;
+  public void setWriteHistory(Map<String, GraphHistory> writeHistory) {
+    this.write_history = writeHistory;
+  }
+
+  @SuppressWarnings("unused")
+  private Map<String, GraphHistory> read_history;
+  public void setReadHistory(Map<String, GraphHistory> readHistory) {
+    this.read_history = readHistory;
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/BandwidthStatus.java b/src/org/torproject/onionoo/docs/BandwidthStatus.java
new file mode 100644
index 0000000..a2980e5
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/BandwidthStatus.java
@@ -0,0 +1,80 @@
+/* Copyright 2013--2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.Scanner;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.torproject.onionoo.util.DateTimeHelper;
+
+public class BandwidthStatus extends Document {
+
+  private SortedMap<Long, long[]> writeHistory =
+      new TreeMap<Long, long[]>();
+  public void setWriteHistory(SortedMap<Long, long[]> writeHistory) {
+    this.writeHistory = writeHistory;
+  }
+  public SortedMap<Long, long[]> getWriteHistory() {
+    return this.writeHistory;
+  }
+
+  private SortedMap<Long, long[]> readHistory =
+      new TreeMap<Long, long[]>();
+  public void setReadHistory(SortedMap<Long, long[]> readHistory) {
+    this.readHistory = readHistory;
+  }
+  public SortedMap<Long, long[]> getReadHistory() {
+    return this.readHistory;
+  }
+
+  public void fromDocumentString(String documentString) {
+    Scanner s = new Scanner(documentString);
+    while (s.hasNextLine()) {
+      String line = s.nextLine();
+      String[] parts = line.split(" ");
+      if (parts.length != 6) {
+        System.err.println("Illegal line '" + line + "' in bandwidth "
+            + "history.  Skipping this line.");
+        continue;
+      }
+      SortedMap<Long, long[]> history = parts[0].equals("r")
+          ? readHistory : writeHistory;
+      long startMillis = DateTimeHelper.parse(parts[1] + " " + parts[2]);
+      long endMillis = DateTimeHelper.parse(parts[3] + " " + parts[4]);
+      if (startMillis < 0L || endMillis < 0L) {
+        System.err.println("Could not parse timestamp while reading "
+            + "bandwidth history.  Skipping.");
+        break;
+      }
+      long bandwidth = Long.parseLong(parts[5]);
+      long previousEndMillis = history.headMap(startMillis).isEmpty()
+          ? startMillis
+          : history.get(history.headMap(startMillis).lastKey())[1];
+      long nextStartMillis = history.tailMap(startMillis).isEmpty()
+          ? endMillis : history.tailMap(startMillis).firstKey();
+      if (previousEndMillis <= startMillis &&
+          nextStartMillis >= endMillis) {
+        history.put(startMillis, new long[] { startMillis, endMillis,
+            bandwidth });
+      }
+    }
+    s.close();
+  }
+
+  public String toDocumentString() {
+    StringBuilder sb = new StringBuilder();
+    for (long[] v : writeHistory.values()) {
+      sb.append("w " + DateTimeHelper.format(v[0]) + " "
+          + DateTimeHelper.format(v[1]) + " " + String.valueOf(v[2])
+          + "\n");
+    }
+    for (long[] v : readHistory.values()) {
+      sb.append("r " + DateTimeHelper.format(v[0]) + " "
+          + DateTimeHelper.format(v[1]) + " " + String.valueOf(v[2])
+          + "\n");
+    }
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/ClientsDocument.java b/src/org/torproject/onionoo/docs/ClientsDocument.java
new file mode 100644
index 0000000..27b1588
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/ClientsDocument.java
@@ -0,0 +1,22 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.Map;
+
+public class ClientsDocument extends Document {
+
+  @SuppressWarnings("unused")
+  private String fingerprint;
+  public void setFingerprint(String fingerprint) {
+    this.fingerprint = fingerprint;
+  }
+
+  @SuppressWarnings("unused")
+  private Map<String, ClientsGraphHistory> average_clients;
+  public void setAverageClients(
+      Map<String, ClientsGraphHistory> averageClients) {
+    this.average_clients = averageClients;
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/ClientsGraphHistory.java b/src/org/torproject/onionoo/docs/ClientsGraphHistory.java
new file mode 100644
index 0000000..e1db663
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/ClientsGraphHistory.java
@@ -0,0 +1,83 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.SortedMap;
+
+public class ClientsGraphHistory {
+
+  private String first;
+  public void setFirst(String first) {
+    this.first = first;
+  }
+  public String getFirst() {
+    return this.first;
+  }
+
+  private String last;
+  public void setLast(String last) {
+    this.last = last;
+  }
+  public String getLast() {
+    return this.last;
+  }
+
+  private Integer interval;
+  public void setInterval(Integer interval) {
+    this.interval = interval;
+  }
+  public Integer getInterval() {
+    return this.interval;
+  }
+
+  private Double factor;
+  public void setFactor(Double factor) {
+    this.factor = factor;
+  }
+  public Double getFactor() {
+    return this.factor;
+  }
+
+  private Integer count;
+  public void setCount(Integer count) {
+    this.count = count;
+  }
+  public Integer getCount() {
+    return this.count;
+  }
+
+  private List<Integer> values = new ArrayList<Integer>();
+  public void setValues(List<Integer> values) {
+    this.values = values;
+  }
+  public List<Integer> getValues() {
+    return this.values;
+  }
+
+  private SortedMap<String, Float> countries;
+  public void setCountries(SortedMap<String, Float> countries) {
+    this.countries = countries;
+  }
+  public SortedMap<String, Float> getCountries() {
+    return this.countries;
+  }
+
+  private SortedMap<String, Float> transports;
+  public void setTransports(SortedMap<String, Float> transports) {
+    this.transports = transports;
+  }
+  public SortedMap<String, Float> getTransports() {
+    return this.transports;
+  }
+
+  private SortedMap<String, Float> versions;
+  public void setVersions(SortedMap<String, Float> versions) {
+    this.versions = versions;
+  }
+  public SortedMap<String, Float> getVersions() {
+    return this.versions;
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/ClientsHistory.java b/src/org/torproject/onionoo/docs/ClientsHistory.java
new file mode 100644
index 0000000..446dd10
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/ClientsHistory.java
@@ -0,0 +1,174 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.torproject.onionoo.util.DateTimeHelper;
+
+public class ClientsHistory implements Comparable<ClientsHistory> {
+
+  private long startMillis;
+  public long getStartMillis() {
+    return this.startMillis;
+  }
+
+  private long endMillis;
+  public long getEndMillis() {
+    return this.endMillis;
+  }
+
+  private double totalResponses;
+  public double getTotalResponses() {
+    return this.totalResponses;
+  }
+
+  private SortedMap<String, Double> responsesByCountry;
+  public SortedMap<String, Double> getResponsesByCountry() {
+    return this.responsesByCountry;
+  }
+
+  private SortedMap<String, Double> responsesByTransport;
+  public SortedMap<String, Double> getResponsesByTransport() {
+    return this.responsesByTransport;
+  }
+
+  private SortedMap<String, Double> responsesByVersion;
+  public SortedMap<String, Double> getResponsesByVersion() {
+    return this.responsesByVersion;
+  }
+
+  public ClientsHistory(long startMillis, long endMillis,
+      double totalResponses,
+      SortedMap<String, Double> responsesByCountry,
+      SortedMap<String, Double> responsesByTransport,
+      SortedMap<String, Double> responsesByVersion) {
+    this.startMillis = startMillis;
+    this.endMillis = endMillis;
+    this.totalResponses = totalResponses;
+    this.responsesByCountry = responsesByCountry;
+    this.responsesByTransport = responsesByTransport;
+    this.responsesByVersion = responsesByVersion;
+  }
+
+  public static ClientsHistory fromString(
+      String responseHistoryString) {
+    String[] parts = responseHistoryString.split(" ", 8);
+    if (parts.length != 8) {
+      return null;
+    }
+    long startMillis = DateTimeHelper.parse(parts[0] + " " + parts[1]);
+    long endMillis = DateTimeHelper.parse(parts[2] + " " + parts[3]);
+    if (startMillis < 0L || endMillis < 0L) {
+      return null;
+    }
+    if (startMillis >= endMillis) {
+      return null;
+    }
+    double totalResponses = 0.0;
+    try {
+      totalResponses = Double.parseDouble(parts[4]);
+    } catch (NumberFormatException e) {
+      return null;
+    }
+    SortedMap<String, Double> responsesByCountry =
+        parseResponses(parts[5]);
+    SortedMap<String, Double> responsesByTransport =
+        parseResponses(parts[6]);
+    SortedMap<String, Double> responsesByVersion =
+        parseResponses(parts[7]);
+    if (responsesByCountry == null || responsesByTransport == null ||
+        responsesByVersion == null) {
+      return null;
+    }
+    return new ClientsHistory(startMillis, endMillis, totalResponses,
+        responsesByCountry, responsesByTransport, responsesByVersion);
+  }
+
+  private static SortedMap<String, Double> parseResponses(
+      String responsesString) {
+    SortedMap<String, Double> responses = new TreeMap<String, Double>();
+    if (responsesString.length() > 0) {
+      for (String pair : responsesString.split(",")) {
+        String[] keyValue = pair.split("=");
+        if (keyValue.length != 2) {
+          return null;
+        }
+        double value = 0.0;
+        try {
+          value = Double.parseDouble(keyValue[1]);
+        } catch (NumberFormatException e) {
+          return null;
+        }
+        responses.put(keyValue[0], value);
+      }
+    }
+    return responses;
+  }
+
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(DateTimeHelper.format(startMillis));
+    sb.append(" " + DateTimeHelper.format(endMillis));
+    sb.append(" " + String.format("%.3f", this.totalResponses));
+    this.appendResponses(sb, this.responsesByCountry);
+    this.appendResponses(sb, this.responsesByTransport);
+    this.appendResponses(sb, this.responsesByVersion);
+    return sb.toString();
+  }
+
+  private void appendResponses(StringBuilder sb,
+      SortedMap<String, Double> responses) {
+    sb.append(" ");
+    int written = 0;
+    for (Map.Entry<String, Double> e : responses.entrySet()) {
+      sb.append((written++ > 0 ? "," : "") + e.getKey() + "="
+          + String.format("%.3f", e.getValue()));
+    }
+  }
+
+  public void addResponses(ClientsHistory other) {
+    this.totalResponses += other.totalResponses;
+    this.addResponsesByCategory(this.responsesByCountry,
+        other.responsesByCountry);
+    this.addResponsesByCategory(this.responsesByTransport,
+        other.responsesByTransport);
+    this.addResponsesByCategory(this.responsesByVersion,
+        other.responsesByVersion);
+    if (this.startMillis > other.startMillis) {
+      this.startMillis = other.startMillis;
+    }
+    if (this.endMillis < other.endMillis) {
+      this.endMillis = other.endMillis;
+    }
+  }
+
+  private void addResponsesByCategory(
+      SortedMap<String, Double> thisResponses,
+      SortedMap<String, Double> otherResponses) {
+    for (Map.Entry<String, Double> e : otherResponses.entrySet()) {
+      if (thisResponses.containsKey(e.getKey())) {
+        thisResponses.put(e.getKey(), thisResponses.get(e.getKey())
+            + e.getValue());
+      } else {
+        thisResponses.put(e.getKey(), e.getValue());
+      }
+    }
+  }
+
+  public int compareTo(ClientsHistory other) {
+    return this.startMillis < other.startMillis ? -1 :
+        this.startMillis > other.startMillis ? 1 : 0;
+  }
+
+  public boolean equals(Object other) {
+    return other instanceof ClientsHistory &&
+        this.startMillis == ((ClientsHistory) other).startMillis;
+  }
+
+  public int hashCode() {
+    return (int) this.startMillis;
+  }
+}
\ No newline at end of file
diff --git a/src/org/torproject/onionoo/docs/ClientsStatus.java b/src/org/torproject/onionoo/docs/ClientsStatus.java
new file mode 100644
index 0000000..2bd2168
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/ClientsStatus.java
@@ -0,0 +1,43 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.Scanner;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+public class ClientsStatus extends Document {
+
+  private SortedSet<ClientsHistory> history =
+      new TreeSet<ClientsHistory>();
+  public void setHistory(SortedSet<ClientsHistory> history) {
+    this.history = history;
+  }
+  public SortedSet<ClientsHistory> getHistory() {
+    return this.history;
+  }
+
+  public void fromDocumentString(String documentString) {
+    Scanner s = new Scanner(documentString);
+    while (s.hasNextLine()) {
+      String line = s.nextLine();
+      ClientsHistory parsedLine = ClientsHistory.fromString(line);
+      if (parsedLine != null) {
+        this.history.add(parsedLine);
+      } else {
+        System.err.println("Could not parse clients history line '"
+            + line + "'.  Skipping.");
+      }
+    }
+    s.close();
+  }
+
+  public String toDocumentString() {
+    StringBuilder sb = new StringBuilder();
+    for (ClientsHistory interval : this.history) {
+      sb.append(interval.toString() + "\n");
+    }
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/DetailsDocument.java b/src/org/torproject/onionoo/docs/DetailsDocument.java
new file mode 100644
index 0000000..142b591
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/DetailsDocument.java
@@ -0,0 +1,365 @@
+/* Copyright 2013--2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang.StringEscapeUtils;
+
+public class DetailsDocument extends Document {
+
+  /* We must ensure that details files only contain ASCII characters
+   * and no UTF-8 characters.  While UTF-8 characters are perfectly
+   * valid in JSON, this would break compatibility with existing files
+   * pretty badly.  We do this by escaping non-ASCII characters, e.g.,
+   * \u00F2.  Gson won't treat this as UTF-8, but will think that we want
+   * to write six characters '\', 'u', '0', '0', 'F', '2'.  The only thing
+   * we'll have to do is to change back the '\\' that Gson writes for the
+   * '\'. */
+  private static String escapeJSON(String s) {
+    return s == null ? null :
+        StringEscapeUtils.escapeJavaScript(s).replaceAll("\\\\'", "'");
+  }
+  private static String unescapeJSON(String s) {
+    return s == null ? null :
+        StringEscapeUtils.unescapeJavaScript(s.replaceAll("'", "\\'"));
+  }
+
+  private String nickname;
+  public void setNickname(String nickname) {
+    this.nickname = nickname;
+  }
+  public String getNickname() {
+    return this.nickname;
+  }
+
+  private String fingerprint;
+  public void setFingerprint(String fingerprint) {
+    this.fingerprint = fingerprint;
+  }
+  public String getFingerprint() {
+    return this.fingerprint;
+  }
+
+  private String hashed_fingerprint;
+  public void setHashedFingerprint(String hashedFingerprint) {
+    this.hashed_fingerprint = hashedFingerprint;
+  }
+  public String getHashedFingerprint() {
+    return this.hashed_fingerprint;
+  }
+
+  private List<String> or_addresses;
+  public void setOrAddresses(List<String> orAddresses) {
+    this.or_addresses = orAddresses;
+  }
+  public List<String> getOrAddresses() {
+    return this.or_addresses;
+  }
+
+  private List<String> exit_addresses;
+  public void setExitAddresses(List<String> exitAddresses) {
+    this.exit_addresses = exitAddresses;
+  }
+  public List<String> getExitAddresses() {
+    return this.exit_addresses;
+  }
+
+  private String dir_address;
+  public void setDirAddress(String dirAddress) {
+    this.dir_address = dirAddress;
+  }
+  public String getDirAddress() {
+    return this.dir_address;
+  }
+
+  private String last_seen;
+  public void setLastSeen(String lastSeen) {
+    this.last_seen = lastSeen;
+  }
+  public String getLastSeen() {
+    return this.last_seen;
+  }
+
+  private String last_changed_address_or_port;
+  public void setLastChangedAddressOrPort(
+      String lastChangedAddressOrPort) {
+    this.last_changed_address_or_port = lastChangedAddressOrPort;
+  }
+  public String getLastChangedAddressOrPort() {
+    return this.last_changed_address_or_port;
+  }
+
+  private String first_seen;
+  public void setFirstSeen(String firstSeen) {
+    this.first_seen = firstSeen;
+  }
+  public String getFirstSeen() {
+    return this.first_seen;
+  }
+
+  private Boolean running;
+  public void setRunning(Boolean running) {
+    this.running = running;
+  }
+  public Boolean getRunning() {
+    return this.running;
+  }
+
+  private List<String> flags;
+  public void setFlags(List<String> flags) {
+    this.flags = flags;
+  }
+  public List<String> getFlags() {
+    return this.flags;
+  }
+
+  private String country;
+  public void setCountry(String country) {
+    this.country = country;
+  }
+  public String getCountry() {
+    return this.country;
+  }
+
+  private String country_name;
+  public void setCountryName(String countryName) {
+    this.country_name = escapeJSON(countryName);
+  }
+  public String getCountryName() {
+    return unescapeJSON(this.country_name);
+  }
+
+  private String region_name;
+  public void setRegionName(String regionName) {
+    this.region_name = escapeJSON(regionName);
+  }
+  public String getRegionName() {
+    return unescapeJSON(this.region_name);
+  }
+
+  private String city_name;
+  public void setCityName(String cityName) {
+    this.city_name = escapeJSON(cityName);
+  }
+  public String getCityName() {
+    return unescapeJSON(this.city_name);
+  }
+
+  private Float latitude;
+  public void setLatitude(Float latitude) {
+    this.latitude = latitude;
+  }
+  public Float getLatitude() {
+    return this.latitude;
+  }
+
+  private Float longitude;
+  public void setLongitude(Float longitude) {
+    this.longitude = longitude;
+  }
+  public Float getLongitude() {
+    return this.longitude;
+  }
+
+  private String as_number;
+  public void setAsNumber(String asNumber) {
+    this.as_number = escapeJSON(asNumber);
+  }
+  public String getAsNumber() {
+    return unescapeJSON(this.as_number);
+  }
+
+  private String as_name;
+  public void setAsName(String asName) {
+    this.as_name = escapeJSON(asName);
+  }
+  public String getAsName() {
+    return unescapeJSON(this.as_name);
+  }
+
+  private Long consensus_weight;
+  public void setConsensusWeight(Long consensusWeight) {
+    this.consensus_weight = consensusWeight;
+  }
+  public Long getConsensusWeight() {
+    return this.consensus_weight;
+  }
+
+  private String host_name;
+  public void setHostName(String hostName) {
+    this.host_name = escapeJSON(hostName);
+  }
+  public String getHostName() {
+    return unescapeJSON(this.host_name);
+  }
+
+  private String last_restarted;
+  public void setLastRestarted(String lastRestarted) {
+    this.last_restarted = lastRestarted;
+  }
+  public String getLastRestarted() {
+    return this.last_restarted;
+  }
+
+  private Integer bandwidth_rate;
+  public void setBandwidthRate(Integer bandwidthRate) {
+    this.bandwidth_rate = bandwidthRate;
+  }
+  public Integer getBandwidthRate() {
+    return this.bandwidth_rate;
+  }
+
+  private Integer bandwidth_burst;
+  public void setBandwidthBurst(Integer bandwidthBurst) {
+    this.bandwidth_burst = bandwidthBurst;
+  }
+  public Integer getBandwidthBurst() {
+    return this.bandwidth_burst;
+  }
+
+  private Integer observed_bandwidth;
+  public void setObservedBandwidth(Integer observedBandwidth) {
+    this.observed_bandwidth = observedBandwidth;
+  }
+  public Integer getObservedBandwidth() {
+    return this.observed_bandwidth;
+  }
+
+  private Integer advertised_bandwidth;
+  public void setAdvertisedBandwidth(Integer advertisedBandwidth) {
+    this.advertised_bandwidth = advertisedBandwidth;
+  }
+  public Integer getAdvertisedBandwidth() {
+    return this.advertised_bandwidth;
+  }
+
+  private List<String> exit_policy;
+  public void setExitPolicy(List<String> exitPolicy) {
+    this.exit_policy = exitPolicy;
+  }
+  public List<String> getExitPolicy() {
+    return this.exit_policy;
+  }
+
+  private Map<String, List<String>> exit_policy_summary;
+  public void setExitPolicySummary(
+      Map<String, List<String>> exitPolicySummary) {
+    this.exit_policy_summary = exitPolicySummary;
+  }
+  public Map<String, List<String>> getExitPolicySummary() {
+    return this.exit_policy_summary;
+  }
+
+  private Map<String, List<String>> exit_policy_v6_summary;
+  public void setExitPolicyV6Summary(
+      Map<String, List<String>> exitPolicyV6Summary) {
+    this.exit_policy_v6_summary = exitPolicyV6Summary;
+  }
+  public Map<String, List<String>> getExitPolicyV6Summary() {
+    return this.exit_policy_v6_summary;
+  }
+
+  private String contact;
+  public void setContact(String contact) {
+    this.contact = escapeJSON(contact);
+  }
+  public String getContact() {
+    return unescapeJSON(this.contact);
+  }
+
+  private String platform;
+  public void setPlatform(String platform) {
+    this.platform = escapeJSON(platform);
+  }
+  public String getPlatform() {
+    return unescapeJSON(this.platform);
+  }
+
+  private List<String> family;
+  public void setFamily(List<String> family) {
+    this.family = family;
+  }
+  public List<String> getFamily() {
+    return this.family;
+  }
+
+  private Float advertised_bandwidth_fraction;
+  public void setAdvertisedBandwidthFraction(
+      Float advertisedBandwidthFraction) {
+    if (advertisedBandwidthFraction == null ||
+        advertisedBandwidthFraction >= 0.0) {
+      this.advertised_bandwidth_fraction = advertisedBandwidthFraction;
+    }
+  }
+  public Float getAdvertisedBandwidthFraction() {
+    return this.advertised_bandwidth_fraction;
+  }
+
+  private Float consensus_weight_fraction;
+  public void setConsensusWeightFraction(Float consensusWeightFraction) {
+    if (consensusWeightFraction == null ||
+        consensusWeightFraction >= 0.0) {
+      this.consensus_weight_fraction = consensusWeightFraction;
+    }
+  }
+  public Float getConsensusWeightFraction() {
+    return this.consensus_weight_fraction;
+  }
+
+  private Float guard_probability;
+  public void setGuardProbability(Float guardProbability) {
+    if (guardProbability == null || guardProbability >= 0.0) {
+      this.guard_probability = guardProbability;
+    }
+  }
+  public Float getGuardProbability() {
+    return this.guard_probability;
+  }
+
+  private Float middle_probability;
+  public void setMiddleProbability(Float middleProbability) {
+    if (middleProbability == null || middleProbability >= 0.0) {
+      this.middle_probability = middleProbability;
+    }
+  }
+  public Float getMiddleProbability() {
+    return this.middle_probability;
+  }
+
+  private Float exit_probability;
+  public void setExitProbability(Float exitProbability) {
+    if (exitProbability == null || exitProbability >= 0.0) {
+      this.exit_probability = exitProbability;
+    }
+  }
+  public Float getExitProbability() {
+    return this.exit_probability;
+  }
+
+  private Boolean recommended_version;
+  public void setRecommendedVersion(Boolean recommendedVersion) {
+    this.recommended_version = recommendedVersion;
+  }
+  public Boolean getRecommendedVersion() {
+    return this.recommended_version;
+  }
+
+  private Boolean hibernating;
+  public void setHibernating(Boolean hibernating) {
+    this.hibernating = hibernating;
+  }
+  public Boolean getHibernating() {
+    return this.hibernating;
+  }
+
+  private String pool_assignment;
+  public void setPoolAssignment(String poolAssignment) {
+    this.pool_assignment = poolAssignment;
+  }
+  public String getPoolAssignment() {
+    return this.pool_assignment;
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/DetailsStatus.java b/src/org/torproject/onionoo/docs/DetailsStatus.java
new file mode 100644
index 0000000..a19b4b9
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/DetailsStatus.java
@@ -0,0 +1,141 @@
+/* Copyright 2013--2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang.StringEscapeUtils;
+
+public class DetailsStatus extends Document {
+
+  /* We must ensure that details files only contain ASCII characters
+   * and no UTF-8 characters.  While UTF-8 characters are perfectly
+   * valid in JSON, this would break compatibility with existing files
+   * pretty badly.  We do this by escaping non-ASCII characters, e.g.,
+   * \u00F2.  Gson won't treat this as UTF-8, but will think that we want
+   * to write six characters '\', 'u', '0', '0', 'F', '2'.  The only thing
+   * we'll have to do is to change back the '\\' that Gson writes for the
+   * '\'. */
+  private static String escapeJSON(String s) {
+    return s == null ? null :
+        StringEscapeUtils.escapeJavaScript(s).replaceAll("\\\\'", "'");
+  }
+  private static String unescapeJSON(String s) {
+    return s == null ? null :
+        StringEscapeUtils.unescapeJavaScript(s.replaceAll("'", "\\'"));
+  }
+
+  private String desc_published;
+  public void setDescPublished(String descPublished) {
+    this.desc_published = descPublished;
+  }
+  public String getDescPublished() {
+    return this.desc_published;
+  }
+
+  private String last_restarted;
+  public void setLastRestarted(String lastRestarted) {
+    this.last_restarted = lastRestarted;
+  }
+  public String getLastRestarted() {
+    return this.last_restarted;
+  }
+
+  private Integer bandwidth_rate;
+  public void setBandwidthRate(Integer bandwidthRate) {
+    this.bandwidth_rate = bandwidthRate;
+  }
+  public Integer getBandwidthRate() {
+    return this.bandwidth_rate;
+  }
+
+  private Integer bandwidth_burst;
+  public void setBandwidthBurst(Integer bandwidthBurst) {
+    this.bandwidth_burst = bandwidthBurst;
+  }
+  public Integer getBandwidthBurst() {
+    return this.bandwidth_burst;
+  }
+
+  private Integer observed_bandwidth;
+  public void setObservedBandwidth(Integer observedBandwidth) {
+    this.observed_bandwidth = observedBandwidth;
+  }
+  public Integer getObservedBandwidth() {
+    return this.observed_bandwidth;
+  }
+
+  private Integer advertised_bandwidth;
+  public void setAdvertisedBandwidth(Integer advertisedBandwidth) {
+    this.advertised_bandwidth = advertisedBandwidth;
+  }
+  public Integer getAdvertisedBandwidth() {
+    return this.advertised_bandwidth;
+  }
+
+  private List<String> exit_policy;
+  public void setExitPolicy(List<String> exitPolicy) {
+    this.exit_policy = exitPolicy;
+  }
+  public List<String> getExitPolicy() {
+    return this.exit_policy;
+  }
+
+  private String contact;
+  public void setContact(String contact) {
+    this.contact = escapeJSON(contact);
+  }
+  public String getContact() {
+    return unescapeJSON(this.contact);
+  }
+
+  private String platform;
+  public void setPlatform(String platform) {
+    this.platform = escapeJSON(platform);
+  }
+  public String getPlatform() {
+    return unescapeJSON(this.platform);
+  }
+
+  private List<String> family;
+  public void setFamily(List<String> family) {
+    this.family = family;
+  }
+  public List<String> getFamily() {
+    return this.family;
+  }
+
+  private Map<String, List<String>> exit_policy_v6_summary;
+  public void setExitPolicyV6Summary(
+      Map<String, List<String>> exitPolicyV6Summary) {
+    this.exit_policy_v6_summary = exitPolicyV6Summary;
+  }
+  public Map<String, List<String>> getExitPolicyV6Summary() {
+    return this.exit_policy_v6_summary;
+  }
+
+  private Boolean hibernating;
+  public void setHibernating(Boolean hibernating) {
+    this.hibernating = hibernating;
+  }
+  public Boolean getHibernating() {
+    return this.hibernating;
+  }
+
+  private String pool_assignment;
+  public void setPoolAssignment(String poolAssignment) {
+    this.pool_assignment = poolAssignment;
+  }
+  public String getPoolAssignment() {
+    return this.pool_assignment;
+  }
+
+  private Map<String, Long> exit_addresses;
+  public void setExitAddresses(Map<String, Long> exitAddresses) {
+    this.exit_addresses = exitAddresses;
+  }
+  public Map<String, Long> getExitAddresses() {
+    return this.exit_addresses;
+  }
+}
diff --git a/src/org/torproject/onionoo/docs/Document.java b/src/org/torproject/onionoo/docs/Document.java
new file mode 100644
index 0000000..a581795
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/Document.java
@@ -0,0 +1,24 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+public abstract class Document {
+
+  private transient String documentString;
+  public void setDocumentString(String documentString) {
+    this.documentString = documentString;
+  }
+  public String getDocumentString() {
+    return this.documentString;
+  }
+
+  public void fromDocumentString(String documentString) {
+    /* Subclasses may override this method to parse documentString. */
+  }
+
+  public String toDocumentString() {
+    /* Subclasses may override this method to write documentString. */
+    return null;
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/DocumentStore.java b/src/org/torproject/onionoo/docs/DocumentStore.java
new file mode 100644
index 0000000..c4fe965
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/DocumentStore.java
@@ -0,0 +1,748 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.Stack;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.Logger;
+import org.torproject.onionoo.util.Time;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonParseException;
+
+// TODO For later migration from disk to database, do the following:
+// - read from database and then from disk if not found
+// - write only to database, delete from disk once in database
+// - move entirely to database once disk is "empty"
+// TODO Also look into simple key-value stores instead of real databases.
+public class DocumentStore {
+
+  private final File statusDir = new File("status");
+
+  private File outDir = new File("out");
+  public void setOutDir(File outDir) {
+    this.outDir = outDir;
+  }
+
+  private Time time;
+
+  public DocumentStore() {
+    this.time = ApplicationFactory.getTime();
+  }
+
+  private long listOperations = 0L, listedFiles = 0L, storedFiles = 0L,
+      storedBytes = 0L, retrievedFiles = 0L, retrievedBytes = 0L,
+      removedFiles = 0L;
+
+  /* Node statuses and summary documents are cached in memory, as opposed
+   * to all other document types.  These caches are initialized when first
+   * accessing or modifying a NodeStatus or SummaryDocument document,
+   * respectively. */
+  private SortedMap<String, NodeStatus> cachedNodeStatuses;
+  private SortedMap<String, SummaryDocument> cachedSummaryDocuments;
+
+  public <T extends Document> SortedSet<String> list(
+      Class<T> documentType) {
+    if (documentType.equals(NodeStatus.class)) {
+      return this.listNodeStatuses();
+    } else if (documentType.equals(SummaryDocument.class)) {
+      return this.listSummaryDocuments();
+    } else {
+      return this.listDocumentFiles(documentType);
+    }
+  }
+
+  private SortedSet<String> listNodeStatuses() {
+    if (this.cachedNodeStatuses == null) {
+      this.cacheNodeStatuses();
+    }
+    return new TreeSet<String>(this.cachedNodeStatuses.keySet());
+  }
+
+  private void cacheNodeStatuses() {
+    SortedMap<String, NodeStatus> parsedNodeStatuses =
+        new TreeMap<String, NodeStatus>();
+    File directory = this.statusDir;
+    if (directory != null) {
+      File summaryFile = new File(directory, "summary");
+      if (summaryFile.exists()) {
+        try {
+          BufferedReader br = new BufferedReader(new FileReader(
+              summaryFile));
+          String line;
+          while ((line = br.readLine()) != null) {
+            if (line.length() == 0) {
+              continue;
+            }
+            NodeStatus node = NodeStatus.fromString(line);
+            if (node != null) {
+              parsedNodeStatuses.put(node.getFingerprint(), node);
+            }
+          }
+          br.close();
+          this.listedFiles += parsedNodeStatuses.size();
+          this.listOperations++;
+        } catch (IOException e) {
+          System.err.println("Could not read file '"
+              + summaryFile.getAbsolutePath() + "'.");
+          e.printStackTrace();
+        }
+      }
+    }
+    this.cachedNodeStatuses = parsedNodeStatuses;
+  }
+
+  private SortedSet<String> listSummaryDocuments() {
+    if (this.cachedSummaryDocuments == null) {
+      this.cacheSummaryDocuments();
+    }
+    return new TreeSet<String>(this.cachedSummaryDocuments.keySet());
+  }
+
+  private void cacheSummaryDocuments() {
+    SortedMap<String, SummaryDocument> parsedSummaryDocuments =
+        new TreeMap<String, SummaryDocument>();
+    File directory = this.outDir;
+    if (directory != null) {
+      File summaryFile = new File(directory, "summary");
+      if (summaryFile.exists()) {
+        String line = null;
+        try {
+          Gson gson = new Gson();
+          BufferedReader br = new BufferedReader(new FileReader(
+              summaryFile));
+          while ((line = br.readLine()) != null) {
+            if (line.length() == 0) {
+              continue;
+            }
+            SummaryDocument summaryDocument = gson.fromJson(line,
+                SummaryDocument.class);
+            if (summaryDocument != null) {
+              parsedSummaryDocuments.put(summaryDocument.getFingerprint(),
+                  summaryDocument);
+            }
+          }
+          br.close();
+          this.listedFiles += parsedSummaryDocuments.size();
+          this.listOperations++;
+        } catch (IOException e) {
+          System.err.println("Could not read file '"
+              + summaryFile.getAbsolutePath() + "'.");
+          e.printStackTrace();
+        } catch (JsonParseException e) {
+          System.err.println("Could not parse summary document '" + line
+              + "' in file '" + summaryFile.getAbsolutePath() + "'.");
+          e.printStackTrace();
+        }
+      }
+    }
+    this.cachedSummaryDocuments = parsedSummaryDocuments;
+  }
+
+  private <T extends Document> SortedSet<String> listDocumentFiles(
+      Class<T> documentType) {
+    SortedSet<String> fingerprints = new TreeSet<String>();
+    File directory = null;
+    String subdirectory = null;
+    if (documentType.equals(DetailsStatus.class)) {
+      directory = this.statusDir;
+      subdirectory = "details";
+    } else if (documentType.equals(BandwidthStatus.class)) {
+      directory = this.statusDir;
+      subdirectory = "bandwidth";
+    } else if (documentType.equals(WeightsStatus.class)) {
+      directory = this.statusDir;
+      subdirectory = "weights";
+    } else if (documentType.equals(ClientsStatus.class)) {
+      directory = this.statusDir;
+      subdirectory = "clients";
+    } else if (documentType.equals(UptimeStatus.class)) {
+      directory = this.statusDir;
+      subdirectory = "uptimes";
+    } else if (documentType.equals(DetailsDocument.class)) {
+      directory = this.outDir;
+      subdirectory = "details";
+    } else if (documentType.equals(BandwidthDocument.class)) {
+      directory = this.outDir;
+      subdirectory = "bandwidth";
+    } else if (documentType.equals(WeightsDocument.class)) {
+      directory = this.outDir;
+      subdirectory = "weights";
+    } else if (documentType.equals(ClientsDocument.class)) {
+      directory = this.outDir;
+      subdirectory = "clients";
+    } else if (documentType.equals(UptimeDocument.class)) {
+      directory = this.outDir;
+      subdirectory = "uptimes";
+    }
+    if (directory != null && subdirectory != null) {
+      Stack<File> files = new Stack<File>();
+      files.add(new File(directory, subdirectory));
+      while (!files.isEmpty()) {
+        File file = files.pop();
+        if (file.isDirectory()) {
+          files.addAll(Arrays.asList(file.listFiles()));
+        } else if (file.getName().length() == 40) {
+            fingerprints.add(file.getName());
+        }
+      }
+    }
+    this.listOperations++;
+    this.listedFiles += fingerprints.size();
+    return fingerprints;
+  }
+
+  public <T extends Document> boolean store(T document) {
+    return this.store(document, null);
+  }
+
+  public <T extends Document> boolean store(T document,
+      String fingerprint) {
+    if (document instanceof NodeStatus) {
+      return this.storeNodeStatus((NodeStatus) document, fingerprint);
+    } else if (document instanceof SummaryDocument) {
+      return this.storeSummaryDocument((SummaryDocument) document,
+          fingerprint);
+    } else {
+      return this.storeDocumentFile(document, fingerprint);
+    }
+  }
+
+  private <T extends Document> boolean storeNodeStatus(
+      NodeStatus nodeStatus, String fingerprint) {
+    if (this.cachedNodeStatuses == null) {
+      this.cacheNodeStatuses();
+    }
+    this.cachedNodeStatuses.put(fingerprint, nodeStatus);
+    return true;
+  }
+
+  private <T extends Document> boolean storeSummaryDocument(
+      SummaryDocument summaryDocument, String fingerprint) {
+    if (this.cachedSummaryDocuments == null) {
+      this.cacheSummaryDocuments();
+    }
+    this.cachedSummaryDocuments.put(fingerprint, summaryDocument);
+    return true;
+  }
+
+  private <T extends Document> boolean storeDocumentFile(T document,
+      String fingerprint) {
+    File documentFile = this.getDocumentFile(document.getClass(),
+        fingerprint);
+    if (documentFile == null) {
+      return false;
+    }
+    String documentString;
+    if (document.getDocumentString() != null) {
+      documentString = document.getDocumentString();
+    } else if (document instanceof BandwidthDocument ||
+          document instanceof WeightsDocument ||
+          document instanceof ClientsDocument ||
+          document instanceof UptimeDocument) {
+      Gson gson = new Gson();
+      documentString = gson.toJson(document);
+    } else if (document instanceof DetailsStatus ||
+        document instanceof DetailsDocument) {
+      /* Don't escape HTML characters, like < and >, contained in
+       * strings. */
+      Gson gson = new GsonBuilder().disableHtmlEscaping().create();
+      /* We must ensure that details files only contain ASCII characters
+       * and no UTF-8 characters.  While UTF-8 characters are perfectly
+       * valid in JSON, this would break compatibility with existing files
+       * pretty badly.  We already make sure that all strings in details
+       * objects are escaped JSON, e.g., \u00F2.  When Gson serlializes
+       * this string, it escapes the \ to \\, hence writes \\u00F2.  We
+       * need to undo this and change \\u00F2 back to \u00F2. */
+      documentString = gson.toJson(document).replaceAll("\\\\\\\\u",
+          "\\\\u");
+      /* Existing details statuses don't contain opening and closing curly
+       * brackets, so we should remove them from new details statuses,
+       * too. */
+       if (document instanceof DetailsStatus) {
+         documentString = documentString.substring(
+             documentString.indexOf("{") + 1,
+             documentString.lastIndexOf("}"));
+       }
+    } else if (document instanceof BandwidthStatus ||
+        document instanceof WeightsStatus ||
+        document instanceof ClientsStatus ||
+        document instanceof UptimeStatus) {
+      documentString = document.toDocumentString();
+    } else {
+      System.err.println("Serializing is not supported for type "
+          + document.getClass().getName() + ".");
+      return false;
+    }
+    try {
+      documentFile.getParentFile().mkdirs();
+      File documentTempFile = new File(
+          documentFile.getAbsolutePath() + ".tmp");
+      BufferedWriter bw = new BufferedWriter(new FileWriter(
+          documentTempFile));
+      bw.write(documentString);
+      bw.close();
+      documentFile.delete();
+      documentTempFile.renameTo(documentFile);
+      this.storedFiles++;
+      this.storedBytes += documentString.length();
+    } catch (IOException e) {
+      System.err.println("Could not write file '"
+          + documentFile.getAbsolutePath() + "'.");
+      e.printStackTrace();
+      return false;
+    }
+    return true;
+  }
+
+  public <T extends Document> T retrieve(Class<T> documentType,
+      boolean parse) {
+    return this.retrieve(documentType, parse, null);
+  }
+
+  public <T extends Document> T retrieve(Class<T> documentType,
+      boolean parse, String fingerprint) {
+    if (documentType.equals(NodeStatus.class)) {
+      return documentType.cast(this.retrieveNodeStatus(fingerprint));
+    } else if (documentType.equals(SummaryDocument.class)) {
+      return documentType.cast(this.retrieveSummaryDocument(fingerprint));
+    } else {
+      return this.retrieveDocumentFile(documentType, parse, fingerprint);
+    }
+  }
+
+  private NodeStatus retrieveNodeStatus(String fingerprint) {
+    if (this.cachedNodeStatuses == null) {
+      this.cacheNodeStatuses();
+    }
+    return this.cachedNodeStatuses.get(fingerprint);
+  }
+
+  private SummaryDocument retrieveSummaryDocument(String fingerprint) {
+    if (this.cachedSummaryDocuments == null) {
+      this.cacheSummaryDocuments();
+    }
+    if (this.cachedSummaryDocuments.containsKey(fingerprint)) {
+      return this.cachedSummaryDocuments.get(fingerprint);
+    }
+    /* TODO This is an evil hack to support looking up relays or bridges
+     * that haven't been running for a week without having to load
+     * 500,000 NodeStatus instances into memory.  Maybe there's a better
+     * way?  Or do we need to switch to a real database for this? */
+    DetailsDocument detailsDocument = this.retrieveDocumentFile(
+        DetailsDocument.class, true, fingerprint);
+    if (detailsDocument == null) {
+      return null;
+    }
+    boolean isRelay = detailsDocument.getHashedFingerprint() == null;
+    boolean running = false;
+    String nickname = detailsDocument.getNickname();
+    List<String> addresses = new ArrayList<String>();
+    String countryCode = null, aSNumber = null, contact = null;
+    for (String orAddressAndPort : detailsDocument.getOrAddresses()) {
+      if (!orAddressAndPort.contains(":")) {
+        return null;
+      }
+      String orAddress = orAddressAndPort.substring(0,
+          orAddressAndPort.lastIndexOf(":"));
+      if (!addresses.contains(orAddress)) {
+        addresses.add(orAddress);
+      }
+    }
+    if (detailsDocument.getExitAddresses() != null) {
+      for (String exitAddress : detailsDocument.getExitAddresses()) {
+        if (!addresses.contains(exitAddress)) {
+          addresses.add(exitAddress);
+        }
+      }
+    }
+    SortedSet<String> relayFlags = new TreeSet<String>(), family = null;
+    long lastSeenMillis = -1L, consensusWeight = -1L,
+        firstSeenMillis = -1L;
+    SummaryDocument summaryDocument = new SummaryDocument(isRelay,
+        nickname, fingerprint, addresses, lastSeenMillis, running,
+        relayFlags, consensusWeight, countryCode, firstSeenMillis,
+        aSNumber, contact, family);
+    return summaryDocument;
+  }
+
+  private <T extends Document> T retrieveDocumentFile(
+      Class<T> documentType, boolean parse, String fingerprint) {
+    File documentFile = this.getDocumentFile(documentType, fingerprint);
+    if (documentFile == null || !documentFile.exists()) {
+      return null;
+    } else if (documentFile.isDirectory()) {
+      System.err.println("Could not read file '"
+          + documentFile.getAbsolutePath() + "', because it is a "
+          + "directory.");
+      return null;
+    }
+    String documentString = null;
+    try {
+      ByteArrayOutputStream baos = new ByteArrayOutputStream();
+      BufferedInputStream bis = new BufferedInputStream(
+          new FileInputStream(documentFile));
+      int len;
+      byte[] data = new byte[1024];
+      while ((len = bis.read(data, 0, 1024)) >= 0) {
+        baos.write(data, 0, len);
+      }
+      bis.close();
+      byte[] allData = baos.toByteArray();
+      if (allData.length == 0) {
+        return null;
+      }
+      documentString = new String(allData, "US-ASCII");
+      this.retrievedFiles++;
+      this.retrievedBytes += documentString.length();
+    } catch (IOException e) {
+      System.err.println("Could not read file '"
+          + documentFile.getAbsolutePath() + "'.");
+      e.printStackTrace();
+      return null;
+    }
+    T result = null;
+    if (!parse) {
+      return this.retrieveUnparsedDocumentFile(documentType,
+          documentString);
+    } else if (documentType.equals(DetailsDocument.class) ||
+        documentType.equals(BandwidthDocument.class) ||
+        documentType.equals(WeightsDocument.class) ||
+        documentType.equals(ClientsDocument.class) ||
+        documentType.equals(UptimeDocument.class)) {
+      return this.retrieveParsedDocumentFile(documentType,
+          documentString);
+    } else if (documentType.equals(BandwidthStatus.class) ||
+        documentType.equals(WeightsStatus.class) ||
+        documentType.equals(ClientsStatus.class) ||
+        documentType.equals(UptimeStatus.class)) {
+      return this.retrieveParsedStatusFile(documentType, documentString);
+    } else if (documentType.equals(DetailsStatus.class)) {
+      return this.retrieveParsedDocumentFile(documentType, "{"
+          + documentString + "}");
+    } else {
+      System.err.println("Parsing is not supported for type "
+          + documentType.getName() + ".");
+    }
+    return result;
+  }
+
+  private <T extends Document> T retrieveParsedStatusFile(
+      Class<T> documentType, String documentString) {
+    T result = null;
+    try {
+      result = documentType.newInstance();
+      result.fromDocumentString(documentString);
+    } catch (InstantiationException e) {
+      /* Handle below. */
+      e.printStackTrace();
+    } catch (IllegalAccessException e) {
+      /* Handle below. */
+      e.printStackTrace();
+    }
+    if (result == null) {
+      System.err.println("Could not initialize parsed status file of "
+          + "type " + documentType.getName() + ".");
+    }
+    return result;
+  }
+
+  private <T extends Document> T retrieveParsedDocumentFile(
+      Class<T> documentType, String documentString) {
+    T result = null;
+    Gson gson = new Gson();
+    try {
+      result = gson.fromJson(documentString, documentType);
+    } catch (JsonParseException e) {
+      /* Handle below. */
+      e.printStackTrace();
+    }
+    if (result == null) {
+      System.err.println("Could not initialize parsed document of type "
+          + documentType.getName() + ".");
+    }
+    return result;
+  }
+
+  private <T extends Document> T retrieveUnparsedDocumentFile(
+      Class<T> documentType, String documentString) {
+    T result = null;
+    try {
+      result = documentType.newInstance();
+      result.setDocumentString(documentString);
+    } catch (InstantiationException e) {
+      /* Handle below. */
+      e.printStackTrace();
+    } catch (IllegalAccessException e) {
+      /* Handle below. */
+      e.printStackTrace();
+    }
+    if (result == null) {
+      System.err.println("Could not initialize unparsed document of type "
+          + documentType.getName() + ".");
+    }
+    return result;
+  }
+
+  public <T extends Document> boolean remove(Class<T> documentType) {
+    return this.remove(documentType, null);
+  }
+
+  public <T extends Document> boolean remove(Class<T> documentType,
+      String fingerprint) {
+    if (documentType.equals(NodeStatus.class)) {
+      return this.removeNodeStatus(fingerprint);
+    } else if (documentType.equals(SummaryDocument.class)) {
+      return this.removeSummaryDocument(fingerprint);
+    } else {
+      return this.removeDocumentFile(documentType, fingerprint);
+    }
+  }
+
+  private boolean removeNodeStatus(String fingerprint) {
+    if (this.cachedNodeStatuses == null) {
+      this.cacheNodeStatuses();
+    }
+    return this.cachedNodeStatuses.remove(fingerprint) != null;
+  }
+
+  private boolean removeSummaryDocument(String fingerprint) {
+    if (this.cachedSummaryDocuments == null) {
+      this.cacheSummaryDocuments();
+    }
+    return this.cachedSummaryDocuments.remove(fingerprint) != null;
+  }
+
+  private <T extends Document> boolean removeDocumentFile(
+      Class<T> documentType, String fingerprint) {
+    File documentFile = this.getDocumentFile(documentType, fingerprint);
+    if (documentFile == null || !documentFile.delete()) {
+      System.err.println("Could not delete file '"
+          + documentFile.getAbsolutePath() + "'.");
+      return false;
+    }
+    this.removedFiles++;
+    return true;
+  }
+
+  private <T extends Document> File getDocumentFile(Class<T> documentType,
+      String fingerprint) {
+    File documentFile = null;
+    if (fingerprint == null && !documentType.equals(UpdateStatus.class) &&
+        !documentType.equals(UptimeStatus.class)) {
+      // TODO Instead of using the update file workaround, add new method
+      // lastModified(Class<T> documentType) that serves a similar
+      // purpose.
+      return null;
+    }
+    File directory = null;
+    String fileName = null;
+    if (documentType.equals(DetailsStatus.class)) {
+      directory = this.statusDir;
+      fileName = String.format("details/%s/%s/%s",
+          fingerprint.substring(0, 1), fingerprint.substring(1, 2),
+          fingerprint);
+    } else if (documentType.equals(BandwidthStatus.class)) {
+      directory = this.statusDir;
+      fileName = String.format("bandwidth/%s/%s/%s",
+          fingerprint.substring(0, 1), fingerprint.substring(1, 2),
+          fingerprint);
+    } else if (documentType.equals(WeightsStatus.class)) {
+      directory = this.statusDir;
+      fileName = String.format("weights/%s/%s/%s",
+          fingerprint.substring(0, 1), fingerprint.substring(1, 2),
+          fingerprint);
+    } else if (documentType.equals(ClientsStatus.class)) {
+      directory = this.statusDir;
+      fileName = String.format("clients/%s/%s/%s",
+          fingerprint.substring(0, 1), fingerprint.substring(1, 2),
+          fingerprint);
+    } else if (documentType.equals(UptimeStatus.class)) {
+      directory = this.statusDir;
+      if (fingerprint == null) {
+        fileName = "uptime";
+      } else {
+        fileName = String.format("uptimes/%s/%s/%s",
+            fingerprint.substring(0, 1), fingerprint.substring(1, 2),
+            fingerprint);
+      }
+    } else if (documentType.equals(UpdateStatus.class)) {
+      directory = this.outDir;
+      fileName = "update";
+    } else if (documentType.equals(DetailsDocument.class)) {
+      directory = this.outDir;
+      fileName = String.format("details/%s", fingerprint);
+    } else if (documentType.equals(BandwidthDocument.class)) {
+      directory = this.outDir;
+      fileName = String.format("bandwidth/%s", fingerprint);
+    } else if (documentType.equals(WeightsDocument.class)) {
+      directory = this.outDir;
+      fileName = String.format("weights/%s", fingerprint);
+    } else if (documentType.equals(ClientsDocument.class)) {
+      directory = this.outDir;
+      fileName = String.format("clients/%s", fingerprint);
+    } else if (documentType.equals(UptimeDocument.class)) {
+      directory = this.outDir;
+      fileName = String.format("uptimes/%s", fingerprint);
+    }
+    if (directory != null && fileName != null) {
+      documentFile = new File(directory, fileName);
+    }
+    return documentFile;
+  }
+
+  public void flushDocumentCache() {
+    /* Write cached node statuses to disk, and write update file
+     * containing current time.  It's important to write the update file
+     * now, not earlier, because the front-end should not read new node
+     * statuses until all details, bandwidths, and weights are ready. */
+    if (this.cachedNodeStatuses != null ||
+        this.cachedSummaryDocuments != null) {
+      if (this.cachedNodeStatuses != null) {
+        this.writeNodeStatuses();
+      }
+      if (this.cachedSummaryDocuments != null) {
+        this.writeSummaryDocuments();
+      }
+      this.writeUpdateStatus();
+    }
+  }
+
+  private void writeNodeStatuses() {
+    File directory = this.statusDir;
+    if (directory == null) {
+      return;
+    }
+    File summaryFile = new File(directory, "summary");
+    SortedMap<String, NodeStatus>
+        cachedRelays = new TreeMap<String, NodeStatus>(),
+        cachedBridges = new TreeMap<String, NodeStatus>();
+    for (Map.Entry<String, NodeStatus> e :
+        this.cachedNodeStatuses.entrySet()) {
+      if (e.getValue().isRelay()) {
+        cachedRelays.put(e.getKey(), e.getValue());
+      } else {
+        cachedBridges.put(e.getKey(), e.getValue());
+      }
+    }
+    StringBuilder sb = new StringBuilder();
+    for (NodeStatus relay : cachedRelays.values()) {
+      String line = relay.toString();
+      if (line != null) {
+        sb.append(line + "\n");
+      } else {
+        System.err.println("Could not serialize relay node status '"
+            + relay.getFingerprint() + "'");
+      }
+    }
+    for (NodeStatus bridge : cachedBridges.values()) {
+      String line = bridge.toString();
+      if (line != null) {
+        sb.append(line + "\n");
+      } else {
+        System.err.println("Could not serialize bridge node status '"
+            + bridge.getFingerprint() + "'");
+      }
+    }
+    String documentString = sb.toString();
+    try {
+      summaryFile.getParentFile().mkdirs();
+      BufferedWriter bw = new BufferedWriter(new FileWriter(summaryFile));
+      bw.write(documentString);
+      bw.close();
+      this.storedFiles++;
+      this.storedBytes += documentString.length();
+    } catch (IOException e) {
+      System.err.println("Could not write file '"
+          + summaryFile.getAbsolutePath() + "'.");
+      e.printStackTrace();
+    }
+  }
+
+  private void writeSummaryDocuments() {
+    StringBuilder sb = new StringBuilder();
+    Gson gson = new Gson();
+    for (SummaryDocument summaryDocument :
+        this.cachedSummaryDocuments.values()) {
+      String line = gson.toJson(summaryDocument);
+      if (line != null) {
+        sb.append(line + "\n");
+      } else {
+        System.err.println("Could not serialize relay summary document '"
+            + summaryDocument.getFingerprint() + "'");
+      }
+    }
+    String documentString = sb.toString();
+    File summaryFile = new File(this.outDir, "summary");
+    try {
+      summaryFile.getParentFile().mkdirs();
+      BufferedWriter bw = new BufferedWriter(new FileWriter(summaryFile));
+      bw.write(documentString);
+      bw.close();
+      this.storedFiles++;
+      this.storedBytes += documentString.length();
+    } catch (IOException e) {
+      System.err.println("Could not write file '"
+          + summaryFile.getAbsolutePath() + "'.");
+      e.printStackTrace();
+    }
+  }
+
+  private void writeUpdateStatus() {
+    if (this.outDir == null) {
+      return;
+    }
+    File updateFile = new File(this.outDir, "update");
+    String documentString = String.valueOf(this.time.currentTimeMillis());
+    try {
+      updateFile.getParentFile().mkdirs();
+      BufferedWriter bw = new BufferedWriter(new FileWriter(updateFile));
+      bw.write(documentString);
+      bw.close();
+      this.storedFiles++;
+      this.storedBytes += documentString.length();
+    } catch (IOException e) {
+      System.err.println("Could not write file '"
+          + updateFile.getAbsolutePath() + "'.");
+      e.printStackTrace();
+    }
+  }
+
+  public String getStatsString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("    " + Logger.formatDecimalNumber(listOperations)
+        + " list operations performed\n");
+    sb.append("    " + Logger.formatDecimalNumber(listedFiles)
+        + " files listed\n");
+    sb.append("    " + Logger.formatDecimalNumber(storedFiles)
+        + " files stored\n");
+    sb.append("    " + Logger.formatBytes(storedBytes) + " stored\n");
+    sb.append("    " + Logger.formatDecimalNumber(retrievedFiles)
+        + " files retrieved\n");
+    sb.append("    " + Logger.formatBytes(retrievedBytes)
+        + " retrieved\n");
+    sb.append("    " + Logger.formatDecimalNumber(removedFiles)
+        + " files removed\n");
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/GraphHistory.java b/src/org/torproject/onionoo/docs/GraphHistory.java
new file mode 100644
index 0000000..19ace31
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/GraphHistory.java
@@ -0,0 +1,56 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.List;
+
+public class GraphHistory {
+
+  private String first;
+  public void setFirst(String first) {
+    this.first = first;
+  }
+  public String getFirst() {
+    return this.first;
+  }
+
+  private String last;
+  public void setLast(String last) {
+    this.last = last;
+  }
+  public String getLast() {
+    return this.last;
+  }
+
+  private Integer interval;
+  public void setInterval(Integer interval) {
+    this.interval = interval;
+  }
+  public Integer getInterval() {
+    return this.interval;
+  }
+
+  private Double factor;
+  public void setFactor(Double factor) {
+    this.factor = factor;
+  }
+  public Double getFactor() {
+    return this.factor;
+  }
+
+  private Integer count;
+  public void setCount(Integer count) {
+    this.count = count;
+  }
+  public Integer getCount() {
+    return this.count;
+  }
+
+  private List<Integer> values;
+  public void setValues(List<Integer> values) {
+    this.values = values;
+  }
+  public List<Integer> getValues() {
+    return this.values;
+  }
+}
diff --git a/src/org/torproject/onionoo/docs/NodeStatus.java b/src/org/torproject/onionoo/docs/NodeStatus.java
new file mode 100644
index 0000000..41292fd
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/NodeStatus.java
@@ -0,0 +1,582 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.apache.commons.codec.DecoderException;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.torproject.onionoo.util.DateTimeHelper;
+
+/* Store search data of a single relay that was running in the past seven
+ * days. */
+public class NodeStatus extends Document {
+
+  private boolean isRelay;
+  public boolean isRelay() {
+    return this.isRelay;
+  }
+
+  private String fingerprint;
+  public String getFingerprint() {
+    return this.fingerprint;
+  }
+
+  private String hashedFingerprint;
+  public String getHashedFingerprint() {
+    return this.hashedFingerprint;
+  }
+
+  private String nickname;
+  public String getNickname() {
+    return this.nickname;
+  }
+
+  private String address;
+  public String getAddress() {
+    return this.address;
+  }
+
+  private SortedSet<String> orAddresses;
+  private SortedSet<String> orAddressesAndPorts;
+  public SortedSet<String> getOrAddresses() {
+    return new TreeSet<String>(this.orAddresses);
+  }
+  public void addOrAddressAndPort(String orAddressAndPort) {
+    if (orAddressAndPort.contains(":") && orAddressAndPort.length() > 0) {
+      String orAddress = orAddressAndPort.substring(0,
+          orAddressAndPort.lastIndexOf(':'));
+      if (this.exitAddresses.contains(orAddress)) {
+        this.exitAddresses.remove(orAddress);
+      }
+      this.orAddresses.add(orAddress);
+      this.orAddressesAndPorts.add(orAddressAndPort);
+    }
+  }
+  public SortedSet<String> getOrAddressesAndPorts() {
+    return new TreeSet<String>(this.orAddressesAndPorts);
+  }
+
+  private SortedSet<String> exitAddresses;
+  public void addExitAddress(String exitAddress) {
+    if (exitAddress.length() > 0 && !this.address.equals(exitAddress) &&
+        !this.orAddresses.contains(exitAddress)) {
+      this.exitAddresses.add(exitAddress);
+    }
+  }
+  public SortedSet<String> getExitAddresses() {
+    return new TreeSet<String>(this.exitAddresses);
+  }
+
+  private Float latitude;
+  public void setLatitude(Float latitude) {
+    this.latitude = latitude;
+  }
+  public Float getLatitude() {
+    return this.latitude;
+  }
+
+  private Float longitude;
+  public void setLongitude(Float longitude) {
+    this.longitude = longitude;
+  }
+  public Float getLongitude() {
+    return this.longitude;
+  }
+
+  private String countryCode;
+  public void setCountryCode(String countryCode) {
+    this.countryCode = countryCode;
+  }
+  public String getCountryCode() {
+    return this.countryCode;
+  }
+
+  private String countryName;
+  public void setCountryName(String countryName) {
+    this.countryName = countryName;
+  }
+  public String getCountryName() {
+    return this.countryName;
+  }
+
+  private String regionName;
+  public void setRegionName(String regionName) {
+    this.regionName = regionName;
+  }
+  public String getRegionName() {
+    return this.regionName;
+  }
+
+  private String cityName;
+  public void setCityName(String cityName) {
+    this.cityName = cityName;
+  }
+  public String getCityName() {
+    return this.cityName;
+  }
+
+  private String aSName;
+  public void setASName(String aSName) {
+    this.aSName = aSName;
+  }
+  public String getASName() {
+    return this.aSName;
+  }
+
+  private String aSNumber;
+  public void setASNumber(String aSNumber) {
+    this.aSNumber = aSNumber;
+  }
+  public String getASNumber() {
+    return this.aSNumber;
+  }
+
+  private long firstSeenMillis;
+  public long getFirstSeenMillis() {
+    return this.firstSeenMillis;
+  }
+
+  private long lastSeenMillis;
+  public long getLastSeenMillis() {
+    return this.lastSeenMillis;
+  }
+
+  private int orPort;
+  public int getOrPort() {
+    return this.orPort;
+  }
+
+  private int dirPort;
+  public int getDirPort() {
+    return this.dirPort;
+  }
+
+  private SortedSet<String> relayFlags;
+  public SortedSet<String> getRelayFlags() {
+    return this.relayFlags;
+  }
+
+  private long consensusWeight;
+  public long getConsensusWeight() {
+    return this.consensusWeight;
+  }
+
+  private boolean running;
+  public void setRunning(boolean running) {
+    this.running = running;
+  }
+  public boolean getRunning() {
+    return this.running;
+  }
+
+  private String hostName;
+  public void setHostName(String hostName) {
+    this.hostName = hostName;
+  }
+  public String getHostName() {
+    return this.hostName;
+  }
+
+  private long lastRdnsLookup = -1L;
+  public void setLastRdnsLookup(long lastRdnsLookup) {
+    this.lastRdnsLookup = lastRdnsLookup;
+  }
+  public long getLastRdnsLookup() {
+    return this.lastRdnsLookup;
+  }
+
+  private double advertisedBandwidthFraction = -1.0;
+  public void setAdvertisedBandwidthFraction(
+      double advertisedBandwidthFraction) {
+    this.advertisedBandwidthFraction = advertisedBandwidthFraction;
+  }
+  public double getAdvertisedBandwidthFraction() {
+    return this.advertisedBandwidthFraction;
+  }
+
+  private double consensusWeightFraction = -1.0;
+  public void setConsensusWeightFraction(double consensusWeightFraction) {
+    this.consensusWeightFraction = consensusWeightFraction;
+  }
+  public double getConsensusWeightFraction() {
+    return this.consensusWeightFraction;
+  }
+
+  private double guardProbability = -1.0;
+  public void setGuardProbability(double guardProbability) {
+    this.guardProbability = guardProbability;
+  }
+  public double getGuardProbability() {
+    return this.guardProbability;
+  }
+
+  private double middleProbability = -1.0;
+  public void setMiddleProbability(double middleProbability) {
+    this.middleProbability = middleProbability;
+  }
+  public double getMiddleProbability() {
+    return this.middleProbability;
+  }
+
+  private double exitProbability = -1.0;
+  public void setExitProbability(double exitProbability) {
+    this.exitProbability = exitProbability;
+  }
+  public double getExitProbability() {
+    return this.exitProbability;
+  }
+
+  private String defaultPolicy;
+  public String getDefaultPolicy() {
+    return this.defaultPolicy;
+  }
+
+  private String portList;
+  public String getPortList() {
+    return this.portList;
+  }
+
+  private SortedMap<Long, Set<String>> lastAddresses;
+  public SortedMap<Long, Set<String>> getLastAddresses() {
+    return this.lastAddresses == null ? null :
+        new TreeMap<Long, Set<String>>(this.lastAddresses);
+  }
+  public long getLastChangedOrAddress() {
+    long lastChangedAddressesMillis = -1L;
+    if (this.lastAddresses != null) {
+      Set<String> lastAddresses = null;
+      for (Map.Entry<Long, Set<String>> e : this.lastAddresses.entrySet()) {
+        if (lastAddresses != null) {
+          for (String address : e.getValue()) {
+            if (!lastAddresses.contains(address)) {
+              return lastChangedAddressesMillis;
+            }
+          }
+        }
+        lastChangedAddressesMillis = e.getKey();
+        lastAddresses = e.getValue();
+      }
+    }
+    return lastChangedAddressesMillis;
+  }
+
+  private String contact;
+  public void setContact(String contact) {
+    if (contact == null) {
+      this.contact = null;
+    } else {
+      StringBuilder sb = new StringBuilder();
+      for (char c : contact.toLowerCase().toCharArray()) {
+        if (c >= 32 && c < 127) {
+          sb.append(c);
+        } else {
+          sb.append(" ");
+        }
+      }
+      this.contact = sb.toString();
+    }
+  }
+  public String getContact() {
+    return this.contact;
+  }
+
+  private Boolean recommendedVersion;
+  public Boolean getRecommendedVersion() {
+    return this.recommendedVersion;
+  }
+
+  private SortedSet<String> familyFingerprints;
+  public void setFamilyFingerprints(
+      SortedSet<String> familyFingerprints) {
+    this.familyFingerprints = familyFingerprints;
+  }
+  public SortedSet<String> getFamilyFingerprints() {
+    return this.familyFingerprints;
+  }
+
+  public NodeStatus(boolean isRelay, String nickname, String fingerprint,
+      String address, SortedSet<String> orAddressesAndPorts,
+      SortedSet<String> exitAddresses, long lastSeenMillis, int orPort,
+      int dirPort, SortedSet<String> relayFlags, long consensusWeight,
+      String countryCode, String hostName, long lastRdnsLookup,
+      String defaultPolicy, String portList, long firstSeenMillis,
+      long lastChangedAddresses, String aSNumber, String contact,
+      Boolean recommendedVersion, SortedSet<String> familyFingerprints) {
+    this.isRelay = isRelay;
+    this.nickname = nickname;
+    this.fingerprint = fingerprint;
+    try {
+      this.hashedFingerprint = DigestUtils.shaHex(Hex.decodeHex(
+          this.fingerprint.toCharArray())).toUpperCase();
+    } catch (DecoderException e) {
+      throw new IllegalArgumentException("Fingerprint '" + fingerprint
+          + "' is not a valid fingerprint.", e);
+    }
+    this.address = address;
+    this.exitAddresses = new TreeSet<String>();
+    if (exitAddresses != null) {
+      this.exitAddresses.addAll(exitAddresses);
+    }
+    this.exitAddresses.remove(this.address);
+    this.orAddresses = new TreeSet<String>();
+    this.orAddressesAndPorts = new TreeSet<String>();
+    if (orAddressesAndPorts != null) {
+      for (String orAddressAndPort : orAddressesAndPorts) {
+        this.addOrAddressAndPort(orAddressAndPort);
+      }
+    }
+    this.lastSeenMillis = lastSeenMillis;
+    this.orPort = orPort;
+    this.dirPort = dirPort;
+    this.relayFlags = relayFlags;
+    this.consensusWeight = consensusWeight;
+    this.countryCode = countryCode;
+    this.hostName = hostName;
+    this.lastRdnsLookup = lastRdnsLookup;
+    this.defaultPolicy = defaultPolicy;
+    this.portList = portList;
+    this.firstSeenMillis = firstSeenMillis;
+    this.lastAddresses =
+        new TreeMap<Long, Set<String>>(Collections.reverseOrder());
+    Set<String> addresses = new HashSet<String>();
+    addresses.add(address + ":" + orPort);
+    if (dirPort > 0) {
+      addresses.add(address + ":" + dirPort);
+    }
+    addresses.addAll(orAddressesAndPorts);
+    this.lastAddresses.put(lastChangedAddresses, addresses);
+    this.aSNumber = aSNumber;
+    this.contact = contact;
+    this.recommendedVersion = recommendedVersion;
+    this.familyFingerprints = familyFingerprints;
+  }
+
+  public static NodeStatus fromString(String documentString) {
+    boolean isRelay = false;
+    String nickname = null, fingerprint = null, address = null,
+        countryCode = null, hostName = null, defaultPolicy = null,
+        portList = null, aSNumber = null, contact = null;
+    SortedSet<String> orAddressesAndPorts = null, exitAddresses = null,
+        relayFlags = null, familyFingerprints = null;
+    long lastSeenMillis = -1L, consensusWeight = -1L,
+        lastRdnsLookup = -1L, firstSeenMillis = -1L,
+        lastChangedAddresses = -1L;
+    int orPort = -1, dirPort = -1;
+    Boolean recommendedVersion = null;
+    try {
+      String separator = documentString.contains("\t") ? "\t" : " ";
+      String[] parts = documentString.trim().split(separator);
+      isRelay = parts[0].equals("r");
+      if (parts.length < 9) {
+        System.err.println("Too few space-separated values in line '"
+            + documentString.trim() + "'.  Skipping.");
+        return null;
+      }
+      nickname = parts[1];
+      fingerprint = parts[2];
+      orAddressesAndPorts = new TreeSet<String>();
+      exitAddresses = new TreeSet<String>();
+      String addresses = parts[3];
+      if (addresses.contains(";")) {
+        String[] addressParts = addresses.split(";", -1);
+        if (addressParts.length != 3) {
+          System.err.println("Invalid addresses entry in line '"
+              + documentString.trim() + "'.  Skipping.");
+          return null;
+        }
+        address = addressParts[0];
+        if (addressParts[1].length() > 0) {
+          orAddressesAndPorts.addAll(Arrays.asList(
+              addressParts[1].split("\\+")));
+        }
+        if (addressParts[2].length() > 0) {
+          exitAddresses.addAll(Arrays.asList(
+              addressParts[2].split("\\+")));
+        }
+      } else {
+        address = addresses;
+      }
+      lastSeenMillis = DateTimeHelper.parse(parts[4] + " " + parts[5]);
+      if (lastSeenMillis < 0L) {
+        System.err.println("Parse exception while parsing node status "
+            + "line '" + documentString + "'.  Skipping.");
+        return null;
+      }
+      orPort = Integer.parseInt(parts[6]);
+      dirPort = Integer.parseInt(parts[7]);
+      relayFlags = new TreeSet<String>();
+      if (parts[8].length() > 0) {
+        relayFlags.addAll(Arrays.asList(parts[8].split(",")));
+      }
+      if (parts.length > 9) {
+        consensusWeight = Long.parseLong(parts[9]);
+      }
+      if (parts.length > 10) {
+        countryCode = parts[10];
+      }
+      if (parts.length > 12) {
+        hostName = parts[11].equals("null") ? null : parts[11];
+        lastRdnsLookup = Long.parseLong(parts[12]);
+      }
+      if (parts.length > 14) {
+        if (!parts[13].equals("null")) {
+          defaultPolicy = parts[13];
+        }
+        if (!parts[14].equals("null")) {
+          portList = parts[14];
+        }
+      }
+      firstSeenMillis = lastSeenMillis;
+      if (parts.length > 16) {
+        firstSeenMillis = DateTimeHelper.parse(parts[15] + " "
+            + parts[16]);
+        if (firstSeenMillis < 0L) {
+          System.err.println("Parse exception while parsing node status "
+              + "line '" + documentString + "'.  Skipping.");
+          return null;
+        }
+      }
+      lastChangedAddresses = lastSeenMillis;
+      if (parts.length > 18 && !parts[17].equals("null")) {
+        lastChangedAddresses = DateTimeHelper.parse(parts[17] + " "
+            + parts[18]);
+        if (lastChangedAddresses < 0L) {
+          System.err.println("Parse exception while parsing node status "
+              + "line '" + documentString + "'.  Skipping.");
+          return null;
+        }
+      }
+      if (parts.length > 19) {
+        aSNumber = parts[19];
+      }
+      if (parts.length > 20) {
+        contact = parts[20];
+      }
+      if (parts.length > 21) {
+        recommendedVersion = parts[21].equals("null") ? null :
+            parts[21].equals("true");
+      }
+      if (parts.length > 22 && !parts[22].equals("null")) {
+        familyFingerprints = new TreeSet<String>(Arrays.asList(
+            parts[22].split(";")));
+      }
+    } catch (NumberFormatException e) {
+      System.err.println("Number format exception while parsing node "
+          + "status line '" + documentString + "': " + e.getMessage()
+          + ".  Skipping.");
+      return null;
+    } catch (Exception e) {
+      /* This catch block is only here to handle yet unknown errors.  It
+       * should go away once we're sure what kind of errors can occur. */
+      System.err.println("Unknown exception while parsing node status "
+          + "line '" + documentString + "': " + e.getMessage() + ".  "
+          + "Skipping.");
+      return null;
+    }
+    NodeStatus newNodeStatus = new NodeStatus(isRelay, nickname,
+        fingerprint, address, orAddressesAndPorts, exitAddresses,
+        lastSeenMillis, orPort, dirPort, relayFlags, consensusWeight,
+        countryCode, hostName, lastRdnsLookup, defaultPolicy, portList,
+        firstSeenMillis, lastChangedAddresses, aSNumber, contact,
+        recommendedVersion, familyFingerprints);
+    return newNodeStatus;
+  }
+
+  public void update(NodeStatus newNodeStatus) {
+    if (newNodeStatus.lastSeenMillis > this.lastSeenMillis) {
+      this.nickname = newNodeStatus.nickname;
+      this.address = newNodeStatus.address;
+      this.orAddressesAndPorts = newNodeStatus.orAddressesAndPorts;
+      this.lastSeenMillis = newNodeStatus.lastSeenMillis;
+      this.orPort = newNodeStatus.orPort;
+      this.dirPort = newNodeStatus.dirPort;
+      this.relayFlags = newNodeStatus.relayFlags;
+      this.consensusWeight = newNodeStatus.consensusWeight;
+      this.countryCode = newNodeStatus.countryCode;
+      this.defaultPolicy = newNodeStatus.defaultPolicy;
+      this.portList = newNodeStatus.portList;
+      this.aSNumber = newNodeStatus.aSNumber;
+      this.recommendedVersion = newNodeStatus.recommendedVersion;
+    }
+    if (this.isRelay && newNodeStatus.isRelay) {
+      this.lastAddresses.putAll(newNodeStatus.lastAddresses);
+    }
+    this.firstSeenMillis = Math.min(newNodeStatus.firstSeenMillis,
+        this.firstSeenMillis);
+  }
+
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(this.isRelay ? "r" : "b");
+    sb.append("\t" + this.nickname);
+    sb.append("\t" + this.fingerprint);
+    sb.append("\t" + this.address + ";");
+    int written = 0;
+    for (String orAddressAndPort : this.orAddressesAndPorts) {
+      sb.append((written++ > 0 ? "+" : "") + orAddressAndPort);
+    }
+    sb.append(";");
+    if (this.isRelay) {
+      written = 0;
+      for (String exitAddress : this.exitAddresses) {
+        sb.append((written++ > 0 ? "+" : "")
+            + exitAddress);
+      }
+    }
+    sb.append("\t" + DateTimeHelper.format(this.lastSeenMillis,
+        DateTimeHelper.ISO_DATETIME_TAB_FORMAT));
+    sb.append("\t" + this.orPort);
+    sb.append("\t" + this.dirPort + "\t");
+    written = 0;
+    for (String relayFlag : this.relayFlags) {
+      sb.append((written++ > 0 ? "," : "") + relayFlag);
+    }
+    if (this.isRelay) {
+      sb.append("\t" + String.valueOf(this.consensusWeight));
+      sb.append("\t"
+          + (this.countryCode != null ? this.countryCode : "??"));
+      sb.append("\t" + (this.hostName != null ? this.hostName : "null"));
+      sb.append("\t" + String.valueOf(this.lastRdnsLookup));
+      sb.append("\t" + (this.defaultPolicy != null ? this.defaultPolicy
+          : "null"));
+      sb.append("\t" + (this.portList != null ? this.portList : "null"));
+    } else {
+      sb.append("\t-1\t??\tnull\t-1\tnull\tnull");
+    }
+    sb.append("\t" + DateTimeHelper.format(this.firstSeenMillis,
+        DateTimeHelper.ISO_DATETIME_TAB_FORMAT));
+    if (this.isRelay) {
+      sb.append("\t" + DateTimeHelper.format(
+          this.getLastChangedOrAddress(),
+          DateTimeHelper.ISO_DATETIME_TAB_FORMAT));
+      sb.append("\t" + (this.aSNumber != null ? this.aSNumber : "null"));
+    } else {
+      sb.append("\tnull\tnull\tnull");
+    }
+    sb.append("\t" + (this.contact != null ? this.contact : ""));
+    sb.append("\t" + (this.recommendedVersion == null ? "null" :
+        this.recommendedVersion ? "true" : "false"));
+    if (this.familyFingerprints == null ||
+        this.familyFingerprints.isEmpty()) {
+      sb.append("\tnull");
+    } else {
+      sb.append("\t");
+      written = 0;
+      for (String familyFingerprint : this.familyFingerprints) {
+        sb.append((written++ > 0 ? ";" : "") + familyFingerprint);
+      }
+    }
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/SummaryDocument.java b/src/org/torproject/onionoo/docs/SummaryDocument.java
new file mode 100644
index 0000000..0c71ae2
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/SummaryDocument.java
@@ -0,0 +1,202 @@
+/* Copyright 2013--2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.regex.Pattern;
+
+import org.apache.commons.codec.DecoderException;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.torproject.onionoo.util.DateTimeHelper;
+
+public class SummaryDocument extends Document {
+
+  private boolean t;
+  public void setRelay(boolean isRelay) {
+    this.t = isRelay;
+  }
+  public boolean isRelay() {
+    return this.t;
+  }
+
+  private String f;
+  public void setFingerprint(String fingerprint) {
+    if (fingerprint != null) {
+      Pattern fingerprintPattern = Pattern.compile("^[0-9a-fA-F]{40}$");
+      if (!fingerprintPattern.matcher(fingerprint).matches()) {
+        throw new IllegalArgumentException("Fingerprint '" + fingerprint
+            + "' is not a valid fingerprint.");
+      }
+    }
+    this.f = fingerprint;
+  }
+  public String getFingerprint() {
+    return this.f;
+  }
+
+  public String getHashedFingerprint() {
+    String hashedFingerprint = null;
+    if (this.f != null) {
+      try {
+        hashedFingerprint = DigestUtils.shaHex(Hex.decodeHex(
+            this.f.toCharArray())).toUpperCase();
+      } catch (DecoderException e) {
+        /* Format tested in setFingerprint(). */
+      }
+    }
+    return hashedFingerprint;
+  }
+
+  private String n;
+  public void setNickname(String nickname) {
+    if (nickname == null || nickname.equals("Unnamed")) {
+      this.n = null;
+    } else {
+      this.n = nickname;
+    }
+  }
+  public String getNickname() {
+    return this.n == null ? "Unnamed" : this.n;
+  }
+
+  private String[] ad;
+  public void setAddresses(List<String> addresses) {
+    this.ad = this.collectionToStringArray(addresses);
+  }
+  public List<String> getAddresses() {
+    return this.stringArrayToList(this.ad);
+  }
+
+  private String[] collectionToStringArray(
+      Collection<String> collection) {
+    String[] stringArray = null;
+    if (collection != null && !collection.isEmpty()) {
+      stringArray = new String[collection.size()];
+      int i = 0;
+      for (String string : collection) {
+        stringArray[i++] = string;
+      }
+    }
+    return stringArray;
+  }
+  private List<String> stringArrayToList(String[] stringArray) {
+    List<String> list;
+    if (stringArray == null) {
+      list = new ArrayList<String>();
+    } else {
+      list = Arrays.asList(stringArray);
+    }
+    return list;
+  }
+  private SortedSet<String> stringArrayToSortedSet(String[] stringArray) {
+    SortedSet<String> sortedSet = new TreeSet<String>();
+    if (stringArray != null) {
+      sortedSet.addAll(Arrays.asList(stringArray));
+    }
+    return sortedSet;
+  }
+
+  private String cc;
+  public void setCountryCode(String countryCode) {
+    this.cc = countryCode;
+  }
+  public String getCountryCode() {
+    return this.cc;
+  }
+
+  private String as;
+  public void setASNumber(String aSNumber) {
+    this.as = aSNumber;
+  }
+  public String getASNumber() {
+    return this.as;
+  }
+
+  private String fs;
+  public void setFirstSeenMillis(long firstSeenMillis) {
+    this.fs = DateTimeHelper.format(firstSeenMillis);
+  }
+  public long getFirstSeenMillis() {
+    return DateTimeHelper.parse(this.fs);
+  }
+
+  private String ls;
+  public void setLastSeenMillis(long lastSeenMillis) {
+    this.ls = DateTimeHelper.format(lastSeenMillis);
+  }
+  public long getLastSeenMillis() {
+    return DateTimeHelper.parse(this.ls);
+  }
+
+  private String[] rf;
+  public void setRelayFlags(SortedSet<String> relayFlags) {
+    this.rf = this.collectionToStringArray(relayFlags);
+  }
+  public SortedSet<String> getRelayFlags() {
+    return this.stringArrayToSortedSet(this.rf);
+  }
+
+  private long cw;
+  public void setConsensusWeight(long consensusWeight) {
+    this.cw = consensusWeight;
+  }
+  public long getConsensusWeight() {
+    return this.cw;
+  }
+
+  private boolean r;
+  public void setRunning(boolean isRunning) {
+    this.r = isRunning;
+  }
+  public boolean isRunning() {
+    return this.r;
+  }
+
+  private String c;
+  public void setContact(String contact) {
+    if (contact != null && contact.length() == 0) {
+      this.c = null;
+    } else {
+      this.c = contact;
+    }
+  }
+  public String getContact() {
+    return this.c;
+  }
+
+  private String[] ff;
+  public void setFamilyFingerprints(
+      SortedSet<String> familyFingerprints) {
+    this.ff = this.collectionToStringArray(familyFingerprints);
+  }
+  public SortedSet<String> getFamilyFingerprints() {
+    return this.stringArrayToSortedSet(this.ff);
+  }
+
+  public SummaryDocument(boolean isRelay, String nickname,
+      String fingerprint, List<String> addresses, long lastSeenMillis,
+      boolean running, SortedSet<String> relayFlags, long consensusWeight,
+      String countryCode, long firstSeenMillis, String aSNumber,
+      String contact, SortedSet<String> familyFingerprints) {
+    this.setRelay(isRelay);
+    this.setNickname(nickname);
+    this.setFingerprint(fingerprint);
+    this.setAddresses(addresses);
+    this.setLastSeenMillis(lastSeenMillis);
+    this.setRunning(running);
+    this.setRelayFlags(relayFlags);
+    this.setConsensusWeight(consensusWeight);
+    this.setCountryCode(countryCode);
+    this.setFirstSeenMillis(firstSeenMillis);
+    this.setASNumber(aSNumber);
+    this.setContact(contact);
+    this.setFamilyFingerprints(familyFingerprints);
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/UpdateStatus.java b/src/org/torproject/onionoo/docs/UpdateStatus.java
new file mode 100644
index 0000000..7bd710b
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/UpdateStatus.java
@@ -0,0 +1,7 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+public class UpdateStatus extends Document {
+}
+
diff --git a/src/org/torproject/onionoo/docs/UptimeDocument.java b/src/org/torproject/onionoo/docs/UptimeDocument.java
new file mode 100644
index 0000000..7f0bacc
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/UptimeDocument.java
@@ -0,0 +1,23 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.Map;
+
+public class UptimeDocument extends Document {
+
+  @SuppressWarnings("unused")
+  private String fingerprint;
+  public void setFingerprint(String fingerprint) {
+    this.fingerprint = fingerprint;
+  }
+
+  private Map<String, GraphHistory> uptime;
+  public void setUptime(Map<String, GraphHistory> uptime) {
+    this.uptime = uptime;
+  }
+  public Map<String, GraphHistory> getUptime() {
+    return this.uptime;
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/UptimeHistory.java b/src/org/torproject/onionoo/docs/UptimeHistory.java
new file mode 100644
index 0000000..f0a966b
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/UptimeHistory.java
@@ -0,0 +1,90 @@
+package org.torproject.onionoo.docs;
+
+import org.torproject.onionoo.util.DateTimeHelper;
+
+public class UptimeHistory
+    implements Comparable<UptimeHistory> {
+
+  private boolean relay;
+  public boolean isRelay() {
+    return this.relay;
+  }
+
+  private long startMillis;
+  public long getStartMillis() {
+    return this.startMillis;
+  }
+
+  private int uptimeHours;
+  public int getUptimeHours() {
+    return this.uptimeHours;
+  }
+
+  UptimeHistory(boolean relay, long startMillis,
+      int uptimeHours) {
+    this.relay = relay;
+    this.startMillis = startMillis;
+    this.uptimeHours = uptimeHours;
+  }
+
+  public static UptimeHistory fromString(String uptimeHistoryString) {
+    String[] parts = uptimeHistoryString.split(" ", 3);
+    if (parts.length != 3) {
+      return null;
+    }
+    boolean relay = false;
+    if (parts[0].equals("r")) {
+      relay = true;
+    } else if (!parts[0].equals("b")) {
+      return null;
+    }
+    long startMillis = DateTimeHelper.parse(parts[1],
+          DateTimeHelper.DATEHOUR_NOSPACE_FORMAT);
+    if (startMillis < 0L) {
+      return null;
+    }
+    int uptimeHours = -1;
+    try {
+      uptimeHours = Integer.parseInt(parts[2]);
+    } catch (NumberFormatException e) {
+      return null;
+    }
+    return new UptimeHistory(relay, startMillis, uptimeHours);
+  }
+
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(this.relay ? "r" : "b");
+    sb.append(" " + DateTimeHelper.format(this.startMillis,
+        DateTimeHelper.DATEHOUR_NOSPACE_FORMAT));
+    sb.append(" " + String.format("%d", this.uptimeHours));
+    return sb.toString();
+  }
+
+  public void addUptime(UptimeHistory other) {
+    this.uptimeHours += other.uptimeHours;
+    if (this.startMillis > other.startMillis) {
+      this.startMillis = other.startMillis;
+    }
+  }
+
+  public int compareTo(UptimeHistory other) {
+    if (this.relay && !other.relay) {
+      return -1;
+    } else if (!this.relay && other.relay) {
+      return 1;
+    }
+    return this.startMillis < other.startMillis ? -1 :
+        this.startMillis > other.startMillis ? 1 : 0;
+  }
+
+  public boolean equals(Object other) {
+    return other instanceof UptimeHistory &&
+        this.relay == ((UptimeHistory) other).relay &&
+        this.startMillis == ((UptimeHistory) other).startMillis;
+  }
+
+  public int hashCode() {
+    return (int) this.startMillis + (this.relay ? 1 : 0);
+  }
+}
\ No newline at end of file
diff --git a/src/org/torproject/onionoo/docs/UptimeStatus.java b/src/org/torproject/onionoo/docs/UptimeStatus.java
new file mode 100644
index 0000000..1da11f0
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/UptimeStatus.java
@@ -0,0 +1,142 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.Scanner;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+
+public class UptimeStatus extends Document {
+
+  private transient String fingerprint;
+
+  private transient boolean isDirty = false;
+
+  private SortedSet<UptimeHistory> relayHistory =
+      new TreeSet<UptimeHistory>();
+  public void setRelayHistory(SortedSet<UptimeHistory> relayHistory) {
+    this.relayHistory = relayHistory;
+  }
+  public SortedSet<UptimeHistory> getRelayHistory() {
+    return this.relayHistory;
+  }
+
+  private SortedSet<UptimeHistory> bridgeHistory =
+      new TreeSet<UptimeHistory>();
+  public void setBridgeHistory(SortedSet<UptimeHistory> bridgeHistory) {
+    this.bridgeHistory = bridgeHistory;
+  }
+  public SortedSet<UptimeHistory> getBridgeHistory() {
+    return this.bridgeHistory;
+  }
+
+  public static UptimeStatus loadOrCreate(String fingerprint) {
+    UptimeStatus uptimeStatus = (fingerprint == null) ?
+        ApplicationFactory.getDocumentStore().retrieve(
+            UptimeStatus.class, true) :
+        ApplicationFactory.getDocumentStore().retrieve(
+            UptimeStatus.class, true, fingerprint);
+    if (uptimeStatus == null) {
+      uptimeStatus = new UptimeStatus();
+    }
+    uptimeStatus.fingerprint = fingerprint;
+    return uptimeStatus;
+  }
+
+  public void fromDocumentString(String documentString) {
+    Scanner s = new Scanner(documentString);
+    while (s.hasNextLine()) {
+      String line = s.nextLine();
+      UptimeHistory parsedLine = UptimeHistory.fromString(line);
+      if (parsedLine != null) {
+        if (parsedLine.isRelay()) {
+          this.relayHistory.add(parsedLine);
+        } else {
+          this.bridgeHistory.add(parsedLine);
+        }
+      } else {
+        System.err.println("Could not parse uptime history line '"
+            + line + "'.  Skipping.");
+      }
+    }
+    s.close();
+  }
+
+  public void addToHistory(boolean relay, SortedSet<Long> newIntervals) {
+    for (long startMillis : newIntervals) {
+      SortedSet<UptimeHistory> history = relay ? this.relayHistory
+          : this.bridgeHistory;
+      UptimeHistory interval = new UptimeHistory(relay, startMillis, 1);
+      if (!history.headSet(interval).isEmpty()) {
+        UptimeHistory prev = history.headSet(interval).last();
+        if (prev.isRelay() == interval.isRelay() &&
+            prev.getStartMillis() + DateTimeHelper.ONE_HOUR
+            * prev.getUptimeHours() > interval.getStartMillis()) {
+          continue;
+        }
+      }
+      if (!history.tailSet(interval).isEmpty()) {
+        UptimeHistory next = history.tailSet(interval).first();
+        if (next.isRelay() == interval.isRelay() &&
+            next.getStartMillis() < interval.getStartMillis()
+            + DateTimeHelper.ONE_HOUR) {
+          continue;
+        }
+      }
+      history.add(interval);
+      this.isDirty = true;
+    }
+  }
+
+  public void storeIfChanged() {
+    if (this.isDirty) {
+      this.compressHistory(this.relayHistory);
+      this.compressHistory(this.bridgeHistory);
+      if (fingerprint == null) {
+        ApplicationFactory.getDocumentStore().store(this);
+      } else {
+        ApplicationFactory.getDocumentStore().store(this,
+            this.fingerprint);
+      }
+      this.isDirty = false;
+    }
+  }
+
+  private void compressHistory(SortedSet<UptimeHistory> history) {
+    SortedSet<UptimeHistory> uncompressedHistory =
+        new TreeSet<UptimeHistory>(history);
+    history.clear();
+    UptimeHistory lastInterval = null;
+    for (UptimeHistory interval : uncompressedHistory) {
+      if (lastInterval != null &&
+          lastInterval.getStartMillis() + DateTimeHelper.ONE_HOUR
+          * lastInterval.getUptimeHours() == interval.getStartMillis() &&
+          lastInterval.isRelay() == interval.isRelay()) {
+        lastInterval.addUptime(interval);
+      } else {
+        if (lastInterval != null) {
+          history.add(lastInterval);
+        }
+        lastInterval = interval;
+      }
+    }
+    if (lastInterval != null) {
+      history.add(lastInterval);
+    }
+  }
+
+  public String toDocumentString() {
+    StringBuilder sb = new StringBuilder();
+    for (UptimeHistory interval : this.relayHistory) {
+      sb.append(interval.toString() + "\n");
+    }
+    for (UptimeHistory interval : this.bridgeHistory) {
+      sb.append(interval.toString() + "\n");
+    }
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/WeightsDocument.java b/src/org/torproject/onionoo/docs/WeightsDocument.java
new file mode 100644
index 0000000..104b661
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/WeightsDocument.java
@@ -0,0 +1,64 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.docs;
+
+import java.util.Map;
+
+public class WeightsDocument extends Document {
+
+  @SuppressWarnings("unused")
+  private String fingerprint;
+  public void setFingerprint(String fingerprint) {
+    this.fingerprint = fingerprint;
+  }
+
+  @SuppressWarnings("unused")
+  private Map<String, GraphHistory> advertised_bandwidth_fraction;
+  public void setAdvertisedBandwidthFraction(
+      Map<String, GraphHistory> advertisedBandwidthFraction) {
+    this.advertised_bandwidth_fraction = advertisedBandwidthFraction;
+  }
+
+  @SuppressWarnings("unused")
+  private Map<String, GraphHistory> consensus_weight_fraction;
+  public void setConsensusWeightFraction(
+      Map<String, GraphHistory> consensusWeightFraction) {
+    this.consensus_weight_fraction = consensusWeightFraction;
+  }
+
+  @SuppressWarnings("unused")
+  private Map<String, GraphHistory> guard_probability;
+  public void setGuardProbability(
+      Map<String, GraphHistory> guardProbability) {
+    this.guard_probability = guardProbability;
+  }
+
+  @SuppressWarnings("unused")
+  private Map<String, GraphHistory> middle_probability;
+  public void setMiddleProbability(
+      Map<String, GraphHistory> middleProbability) {
+    this.middle_probability = middleProbability;
+  }
+
+  @SuppressWarnings("unused")
+  private Map<String, GraphHistory> exit_probability;
+  public void setExitProbability(
+      Map<String, GraphHistory> exitProbability) {
+    this.exit_probability = exitProbability;
+  }
+
+  @SuppressWarnings("unused")
+  private Map<String, GraphHistory> advertised_bandwidth;
+  public void setAdvertisedBandwidth(
+      Map<String, GraphHistory> advertisedBandwidth) {
+    this.advertised_bandwidth = advertisedBandwidth;
+  }
+
+  @SuppressWarnings("unused")
+  private Map<String, GraphHistory> consensus_weight;
+  public void setConsensusWeight(
+      Map<String, GraphHistory> consensusWeight) {
+    this.consensus_weight = consensusWeight;
+  }
+}
+
diff --git a/src/org/torproject/onionoo/docs/WeightsStatus.java b/src/org/torproject/onionoo/docs/WeightsStatus.java
new file mode 100644
index 0000000..678789b
--- /dev/null
+++ b/src/org/torproject/onionoo/docs/WeightsStatus.java
@@ -0,0 +1,99 @@
+package org.torproject.onionoo.docs;
+
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.torproject.onionoo.util.DateTimeHelper;
+
+public class WeightsStatus extends Document {
+
+  private SortedMap<long[], double[]> history =
+      new TreeMap<long[], double[]>(new Comparator<long[]>() {
+    public int compare(long[] a, long[] b) {
+      return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
+    }
+  });
+  public void setHistory(SortedMap<long[], double[]> history) {
+    this.history = history;
+  }
+  public SortedMap<long[], double[]> getHistory() {
+    return this.history;
+  }
+
+  private Map<String, Integer> advertisedBandwidths =
+      new HashMap<String, Integer>();
+  public Map<String, Integer> getAdvertisedBandwidths() {
+    return this.advertisedBandwidths;
+  }
+
+  public void fromDocumentString(String documentString) {
+    Scanner s = new Scanner(documentString);
+    while (s.hasNextLine()) {
+      String line = s.nextLine();
+      String[] parts = line.split(" ");
+      if (parts.length == 2) {
+        String descriptorDigest = parts[0];
+        int advertisedBandwidth = Integer.parseInt(parts[1]);
+        this.advertisedBandwidths.put(descriptorDigest,
+            advertisedBandwidth);
+        continue;
+      }
+      if (parts.length != 9 && parts.length != 11) {
+        System.err.println("Illegal line '" + line + "' in weights "
+              + "status file.  Skipping this line.");
+        continue;
+      }
+      if (parts[4].equals("NaN")) {
+        /* Remove corrupt lines written on 2013-07-07 and the days
+         * after. */
+        continue;
+      }
+      long validAfterMillis = DateTimeHelper.parse(parts[0] + " "
+          + parts[1]);
+      long freshUntilMillis = DateTimeHelper.parse(parts[2] + " "
+          + parts[3]);
+      if (validAfterMillis < 0L || freshUntilMillis < 0L) {
+        System.err.println("Could not parse timestamp while reading "
+            + "weights status file.  Skipping.");
+        break;
+      }
+      long[] interval = new long[] { validAfterMillis, freshUntilMillis };
+      double[] weights = new double[] {
+          Double.parseDouble(parts[4]),
+          Double.parseDouble(parts[5]),
+          Double.parseDouble(parts[6]),
+          Double.parseDouble(parts[7]),
+          Double.parseDouble(parts[8]), -1.0, -1.0 };
+      if (parts.length == 11) {
+        weights[5] = Double.parseDouble(parts[9]);
+        weights[6] = Double.parseDouble(parts[10]);
+      }
+      this.history.put(interval, weights);
+    }
+    s.close();
+  }
+
+  public String toDocumentString() {
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, Integer> e :
+        this.advertisedBandwidths.entrySet()) {
+      sb.append(e.getKey() + " " + String.valueOf(e.getValue()) + "\n");
+    }
+    for (Map.Entry<long[], double[]> e : history.entrySet()) {
+      long[] fresh = e.getKey();
+      double[] weights = e.getValue();
+      sb.append(DateTimeHelper.format(fresh[0]) + " "
+          + DateTimeHelper.format(fresh[1]));
+      for (double weight : weights) {
+        sb.append(String.format(" %.12f", weight));
+      }
+      sb.append("\n");
+    }
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/server/NodeIndexer.java b/src/org/torproject/onionoo/server/NodeIndexer.java
new file mode 100644
index 0000000..b76f4c1
--- /dev/null
+++ b/src/org/torproject/onionoo/server/NodeIndexer.java
@@ -0,0 +1,432 @@
+package org.torproject.onionoo.server;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+
+import javax.servlet.ServletContext;
+import javax.servlet.ServletContextEvent;
+import javax.servlet.ServletContextListener;
+
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.docs.SummaryDocument;
+import org.torproject.onionoo.docs.UpdateStatus;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Time;
+
+class NodeIndex {
+
+  private String relaysPublishedString;
+  public void setRelaysPublishedString(String relaysPublishedString) {
+    this.relaysPublishedString = relaysPublishedString;
+  }
+  public String getRelaysPublishedString() {
+    return relaysPublishedString;
+  }
+
+  private String bridgesPublishedString;
+  public void setBridgesPublishedString(String bridgesPublishedString) {
+    this.bridgesPublishedString = bridgesPublishedString;
+  }
+  public String getBridgesPublishedString() {
+    return bridgesPublishedString;
+  }
+
+  private List<String> relaysByConsensusWeight;
+  public void setRelaysByConsensusWeight(
+      List<String> relaysByConsensusWeight) {
+    this.relaysByConsensusWeight = relaysByConsensusWeight;
+  }
+  public List<String> getRelaysByConsensusWeight() {
+    return relaysByConsensusWeight;
+  }
+
+
+  private Map<String, SummaryDocument> relayFingerprintSummaryLines;
+  public void setRelayFingerprintSummaryLines(
+      Map<String, SummaryDocument> relayFingerprintSummaryLines) {
+    this.relayFingerprintSummaryLines = relayFingerprintSummaryLines;
+  }
+  public Map<String, SummaryDocument> getRelayFingerprintSummaryLines() {
+    return this.relayFingerprintSummaryLines;
+  }
+
+  private Map<String, SummaryDocument> bridgeFingerprintSummaryLines;
+  public void setBridgeFingerprintSummaryLines(
+      Map<String, SummaryDocument> bridgeFingerprintSummaryLines) {
+    this.bridgeFingerprintSummaryLines = bridgeFingerprintSummaryLines;
+  }
+  public Map<String, SummaryDocument> getBridgeFingerprintSummaryLines() {
+    return this.bridgeFingerprintSummaryLines;
+  }
+
+  private Map<String, Set<String>> relaysByCountryCode = null;
+  public void setRelaysByCountryCode(
+      Map<String, Set<String>> relaysByCountryCode) {
+    this.relaysByCountryCode = relaysByCountryCode;
+  }
+  public Map<String, Set<String>> getRelaysByCountryCode() {
+    return relaysByCountryCode;
+  }
+
+  private Map<String, Set<String>> relaysByASNumber = null;
+  public void setRelaysByASNumber(
+      Map<String, Set<String>> relaysByASNumber) {
+    this.relaysByASNumber = relaysByASNumber;
+  }
+  public Map<String, Set<String>> getRelaysByASNumber() {
+    return relaysByASNumber;
+  }
+
+  private Map<String, Set<String>> relaysByFlag = null;
+  public void setRelaysByFlag(Map<String, Set<String>> relaysByFlag) {
+    this.relaysByFlag = relaysByFlag;
+  }
+  public Map<String, Set<String>> getRelaysByFlag() {
+    return relaysByFlag;
+  }
+
+  private Map<String, Set<String>> bridgesByFlag = null;
+  public void setBridgesByFlag(Map<String, Set<String>> bridgesByFlag) {
+    this.bridgesByFlag = bridgesByFlag;
+  }
+  public Map<String, Set<String>> getBridgesByFlag() {
+    return bridgesByFlag;
+  }
+
+  private Map<String, Set<String>> relaysByContact = null;
+  public void setRelaysByContact(
+      Map<String, Set<String>> relaysByContact) {
+    this.relaysByContact = relaysByContact;
+  }
+  public Map<String, Set<String>> getRelaysByContact() {
+    return relaysByContact;
+  }
+
+  private Map<String, Set<String>> relaysByFamily = null;
+  public void setRelaysByFamily(Map<String, Set<String>> relaysByFamily) {
+    this.relaysByFamily = relaysByFamily;
+  }
+  public Map<String, Set<String>> getRelaysByFamily() {
+    return this.relaysByFamily;
+  }
+
+  private SortedMap<Integer, Set<String>> relaysByFirstSeenDays;
+  public void setRelaysByFirstSeenDays(
+      SortedMap<Integer, Set<String>> relaysByFirstSeenDays) {
+    this.relaysByFirstSeenDays = relaysByFirstSeenDays;
+  }
+  public SortedMap<Integer, Set<String>> getRelaysByFirstSeenDays() {
+    return relaysByFirstSeenDays;
+  }
+
+  private SortedMap<Integer, Set<String>> bridgesByFirstSeenDays;
+  public void setBridgesByFirstSeenDays(
+      SortedMap<Integer, Set<String>> bridgesByFirstSeenDays) {
+    this.bridgesByFirstSeenDays = bridgesByFirstSeenDays;
+  }
+  public SortedMap<Integer, Set<String>> getBridgesByFirstSeenDays() {
+    return bridgesByFirstSeenDays;
+  }
+
+  private SortedMap<Integer, Set<String>> relaysByLastSeenDays;
+  public void setRelaysByLastSeenDays(
+      SortedMap<Integer, Set<String>> relaysByLastSeenDays) {
+    this.relaysByLastSeenDays = relaysByLastSeenDays;
+  }
+  public SortedMap<Integer, Set<String>> getRelaysByLastSeenDays() {
+    return relaysByLastSeenDays;
+  }
+
+  private SortedMap<Integer, Set<String>> bridgesByLastSeenDays;
+  public void setBridgesByLastSeenDays(
+      SortedMap<Integer, Set<String>> bridgesByLastSeenDays) {
+    this.bridgesByLastSeenDays = bridgesByLastSeenDays;
+  }
+  public SortedMap<Integer, Set<String>> getBridgesByLastSeenDays() {
+    return bridgesByLastSeenDays;
+  }
+}
+
+public class NodeIndexer implements ServletContextListener, Runnable {
+
+  public void contextInitialized(ServletContextEvent contextEvent) {
+    ServletContext servletContext = contextEvent.getServletContext();
+    File outDir = new File(servletContext.getInitParameter("outDir"));
+    DocumentStore documentStore = ApplicationFactory.getDocumentStore();
+    documentStore.setOutDir(outDir);
+    /* The servlet container created us, and we need to avoid that
+     * ApplicationFactory creates another instance of us. */
+    ApplicationFactory.setNodeIndexer(this);
+    this.startIndexing();
+  }
+
+  public void contextDestroyed(ServletContextEvent contextEvent) {
+    this.stopIndexing();
+  }
+
+  private long lastIndexed = -1L;
+
+  private NodeIndex latestNodeIndex = null;
+
+  private Thread nodeIndexerThread = null;
+
+  public synchronized long getLastIndexed(long timeoutMillis) {
+    if (this.lastIndexed == -1L && this.nodeIndexerThread != null &&
+        timeoutMillis > 0L) {
+      try {
+        this.wait(timeoutMillis);
+      } catch (InterruptedException e) {
+      }
+    }
+    return this.lastIndexed;
+  }
+
+  public synchronized NodeIndex getLatestNodeIndex(long timeoutMillis) {
+    if (this.latestNodeIndex == null && this.nodeIndexerThread != null &&
+        timeoutMillis > 0L) {
+      try {
+        this.wait(timeoutMillis);
+      } catch (InterruptedException e) {
+      }
+    }
+    return this.latestNodeIndex;
+  }
+
+  public synchronized void startIndexing() {
+    if (this.nodeIndexerThread == null) {
+      this.nodeIndexerThread = new Thread(this);
+      this.nodeIndexerThread.setDaemon(true);
+      this.nodeIndexerThread.start();
+    }
+  }
+
+  public void run() {
+    while (this.nodeIndexerThread != null) {
+      this.indexNodeStatuses();
+      try {
+        Thread.sleep(DateTimeHelper.ONE_MINUTE);
+      } catch (InterruptedException e) {
+      }
+    }
+  }
+
+  public synchronized void stopIndexing() {
+    Thread indexerThread = this.nodeIndexerThread;
+    this.nodeIndexerThread = null;
+    indexerThread.interrupt();
+  }
+
+  private void indexNodeStatuses() {
+    long updateStatusMillis = -1L;
+    DocumentStore documentStore = ApplicationFactory.getDocumentStore();
+    UpdateStatus updateStatus = documentStore.retrieve(UpdateStatus.class,
+        false);
+    if (updateStatus != null &&
+        updateStatus.getDocumentString() != null) {
+      String updateString = updateStatus.getDocumentString();
+      try {
+        updateStatusMillis = Long.parseLong(updateString.trim());
+      } catch (NumberFormatException e) {
+        /* Handle below. */
+      }
+    }
+    synchronized (this) {
+      if (updateStatusMillis <= this.lastIndexed) {
+        return;
+      }
+    }
+    List<String> newRelaysByConsensusWeight = new ArrayList<String>();
+    Map<String, SummaryDocument>
+        newRelayFingerprintSummaryLines =
+        new HashMap<String, SummaryDocument>(),
+        newBridgeFingerprintSummaryLines =
+        new HashMap<String, SummaryDocument>();
+    Map<String, Set<String>>
+        newRelaysByCountryCode = new HashMap<String, Set<String>>(),
+        newRelaysByASNumber = new HashMap<String, Set<String>>(),
+        newRelaysByFlag = new HashMap<String, Set<String>>(),
+        newBridgesByFlag = new HashMap<String, Set<String>>(),
+        newRelaysByContact = new HashMap<String, Set<String>>(),
+        newRelaysByFamily = new HashMap<String, Set<String>>();
+    SortedMap<Integer, Set<String>>
+        newRelaysByFirstSeenDays = new TreeMap<Integer, Set<String>>(),
+        newBridgesByFirstSeenDays = new TreeMap<Integer, Set<String>>(),
+        newRelaysByLastSeenDays = new TreeMap<Integer, Set<String>>(),
+        newBridgesByLastSeenDays = new TreeMap<Integer, Set<String>>();
+    Set<SummaryDocument> currentRelays = new HashSet<SummaryDocument>(),
+        currentBridges = new HashSet<SummaryDocument>();
+    SortedSet<String> fingerprints = documentStore.list(
+        SummaryDocument.class);
+    long relaysLastValidAfterMillis = 0L, bridgesLastPublishedMillis = 0L;
+    for (String fingerprint : fingerprints) {
+      SummaryDocument node = documentStore.retrieve(SummaryDocument.class,
+          true, fingerprint);
+      if (node.isRelay()) {
+        relaysLastValidAfterMillis = Math.max(
+            relaysLastValidAfterMillis, node.getLastSeenMillis());
+        currentRelays.add(node);
+      } else {
+        bridgesLastPublishedMillis = Math.max(
+            bridgesLastPublishedMillis, node.getLastSeenMillis());
+        currentBridges.add(node);
+      }
+    }
+    Time time = ApplicationFactory.getTime();
+    List<String> orderRelaysByConsensusWeight = new ArrayList<String>();
+    for (SummaryDocument entry : currentRelays) {
+      String fingerprint = entry.getFingerprint().toUpperCase();
+      String hashedFingerprint = entry.getHashedFingerprint().
+          toUpperCase();
+      newRelayFingerprintSummaryLines.put(fingerprint, entry);
+      newRelayFingerprintSummaryLines.put(hashedFingerprint, entry);
+      long consensusWeight = entry.getConsensusWeight();
+      orderRelaysByConsensusWeight.add(String.format("%020d %s",
+          consensusWeight, fingerprint));
+      orderRelaysByConsensusWeight.add(String.format("%020d %s",
+          consensusWeight, hashedFingerprint));
+      if (entry.getCountryCode() != null) {
+        String countryCode = entry.getCountryCode();
+        if (!newRelaysByCountryCode.containsKey(countryCode)) {
+          newRelaysByCountryCode.put(countryCode,
+              new HashSet<String>());
+        }
+        newRelaysByCountryCode.get(countryCode).add(fingerprint);
+        newRelaysByCountryCode.get(countryCode).add(hashedFingerprint);
+      }
+      if (entry.getASNumber() != null) {
+        String aSNumber = entry.getASNumber();
+        if (!newRelaysByASNumber.containsKey(aSNumber)) {
+          newRelaysByASNumber.put(aSNumber, new HashSet<String>());
+        }
+        newRelaysByASNumber.get(aSNumber).add(fingerprint);
+        newRelaysByASNumber.get(aSNumber).add(hashedFingerprint);
+      }
+      for (String flag : entry.getRelayFlags()) {
+        String flagLowerCase = flag.toLowerCase();
+        if (!newRelaysByFlag.containsKey(flagLowerCase)) {
+          newRelaysByFlag.put(flagLowerCase, new HashSet<String>());
+        }
+        newRelaysByFlag.get(flagLowerCase).add(fingerprint);
+        newRelaysByFlag.get(flagLowerCase).add(hashedFingerprint);
+      }
+      if (entry.getFamilyFingerprints() != null) {
+        newRelaysByFamily.put(fingerprint, entry.getFamilyFingerprints());
+      }
+      int daysSinceFirstSeen = (int) ((time.currentTimeMillis()
+          - entry.getFirstSeenMillis()) / DateTimeHelper.ONE_DAY);
+      if (!newRelaysByFirstSeenDays.containsKey(daysSinceFirstSeen)) {
+        newRelaysByFirstSeenDays.put(daysSinceFirstSeen,
+            new HashSet<String>());
+      }
+      newRelaysByFirstSeenDays.get(daysSinceFirstSeen).add(fingerprint);
+      newRelaysByFirstSeenDays.get(daysSinceFirstSeen).add(
+          hashedFingerprint);
+      int daysSinceLastSeen = (int) ((time.currentTimeMillis()
+          - entry.getLastSeenMillis()) / DateTimeHelper.ONE_DAY);
+      if (!newRelaysByLastSeenDays.containsKey(daysSinceLastSeen)) {
+        newRelaysByLastSeenDays.put(daysSinceLastSeen,
+            new HashSet<String>());
+      }
+      newRelaysByLastSeenDays.get(daysSinceLastSeen).add(fingerprint);
+      newRelaysByLastSeenDays.get(daysSinceLastSeen).add(
+          hashedFingerprint);
+      String contact = entry.getContact();
+      if (!newRelaysByContact.containsKey(contact)) {
+        newRelaysByContact.put(contact, new HashSet<String>());
+      }
+      newRelaysByContact.get(contact).add(fingerprint);
+      newRelaysByContact.get(contact).add(hashedFingerprint);
+    }
+    Collections.sort(orderRelaysByConsensusWeight);
+    newRelaysByConsensusWeight = new ArrayList<String>();
+    for (String relay : orderRelaysByConsensusWeight) {
+      newRelaysByConsensusWeight.add(relay.split(" ")[1]);
+    }
+    for (Map.Entry<String, Set<String>> e :
+        newRelaysByFamily.entrySet()) {
+      String fingerprint = e.getKey();
+      Set<String> inMutualFamilyRelation = new HashSet<String>();
+      for (String otherFingerprint : e.getValue()) {
+        if (newRelaysByFamily.containsKey(otherFingerprint) &&
+            newRelaysByFamily.get(otherFingerprint).contains(
+                fingerprint)) {
+          inMutualFamilyRelation.add(otherFingerprint);
+        }
+      }
+      e.getValue().retainAll(inMutualFamilyRelation);
+    }
+    for (SummaryDocument entry : currentBridges) {
+      String hashedFingerprint = entry.getFingerprint().toUpperCase();
+      String hashedHashedFingerprint = entry.getHashedFingerprint().
+          toUpperCase();
+      newBridgeFingerprintSummaryLines.put(hashedFingerprint, entry);
+      newBridgeFingerprintSummaryLines.put(hashedHashedFingerprint,
+          entry);
+      for (String flag : entry.getRelayFlags()) {
+        String flagLowerCase = flag.toLowerCase();
+        if (!newBridgesByFlag.containsKey(flagLowerCase)) {
+          newBridgesByFlag.put(flagLowerCase, new HashSet<String>());
+        }
+        newBridgesByFlag.get(flagLowerCase).add(hashedFingerprint);
+        newBridgesByFlag.get(flagLowerCase).add(
+            hashedHashedFingerprint);
+      }
+      int daysSinceFirstSeen = (int) ((time.currentTimeMillis()
+          - entry.getFirstSeenMillis()) / DateTimeHelper.ONE_DAY);
+      if (!newBridgesByFirstSeenDays.containsKey(daysSinceFirstSeen)) {
+        newBridgesByFirstSeenDays.put(daysSinceFirstSeen,
+            new HashSet<String>());
+      }
+      newBridgesByFirstSeenDays.get(daysSinceFirstSeen).add(
+          hashedFingerprint);
+      newBridgesByFirstSeenDays.get(daysSinceFirstSeen).add(
+          hashedHashedFingerprint);
+      int daysSinceLastSeen = (int) ((time.currentTimeMillis()
+          - entry.getLastSeenMillis()) / DateTimeHelper.ONE_DAY);
+      if (!newBridgesByLastSeenDays.containsKey(daysSinceLastSeen)) {
+        newBridgesByLastSeenDays.put(daysSinceLastSeen,
+            new HashSet<String>());
+      }
+      newBridgesByLastSeenDays.get(daysSinceLastSeen).add(
+          hashedFingerprint);
+      newBridgesByLastSeenDays.get(daysSinceLastSeen).add(
+          hashedHashedFingerprint);
+    }
+    NodeIndex newNodeIndex = new NodeIndex();
+    newNodeIndex.setRelaysByConsensusWeight(newRelaysByConsensusWeight);
+    newNodeIndex.setRelayFingerprintSummaryLines(
+        newRelayFingerprintSummaryLines);
+    newNodeIndex.setBridgeFingerprintSummaryLines(
+        newBridgeFingerprintSummaryLines);
+    newNodeIndex.setRelaysByCountryCode(newRelaysByCountryCode);
+    newNodeIndex.setRelaysByASNumber(newRelaysByASNumber);
+    newNodeIndex.setRelaysByFlag(newRelaysByFlag);
+    newNodeIndex.setBridgesByFlag(newBridgesByFlag);
+    newNodeIndex.setRelaysByContact(newRelaysByContact);
+    newNodeIndex.setRelaysByFamily(newRelaysByFamily);
+    newNodeIndex.setRelaysByFirstSeenDays(newRelaysByFirstSeenDays);
+    newNodeIndex.setRelaysByLastSeenDays(newRelaysByLastSeenDays);
+    newNodeIndex.setBridgesByFirstSeenDays(newBridgesByFirstSeenDays);
+    newNodeIndex.setBridgesByLastSeenDays(newBridgesByLastSeenDays);
+    newNodeIndex.setRelaysPublishedString(DateTimeHelper.format(
+        relaysLastValidAfterMillis));
+    newNodeIndex.setBridgesPublishedString(DateTimeHelper.format(
+        bridgesLastPublishedMillis));
+    synchronized (this) {
+      this.lastIndexed = updateStatusMillis;
+      this.latestNodeIndex = newNodeIndex;
+      this.notifyAll();
+    }
+  }
+}
+
diff --git a/src/org/torproject/onionoo/server/RequestHandler.java b/src/org/torproject/onionoo/server/RequestHandler.java
new file mode 100644
index 0000000..22e82fb
--- /dev/null
+++ b/src/org/torproject/onionoo/server/RequestHandler.java
@@ -0,0 +1,552 @@
+/* Copyright 2011--2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.server;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.docs.SummaryDocument;
+import org.torproject.onionoo.util.ApplicationFactory;
+
+public class RequestHandler {
+
+  private NodeIndex nodeIndex;
+
+  private DocumentStore documentStore;
+
+  public RequestHandler(NodeIndex nodeIndex) {
+    this.nodeIndex = nodeIndex;
+    this.documentStore = ApplicationFactory.getDocumentStore();
+  }
+
+  private String resourceType;
+  public void setResourceType(String resourceType) {
+    this.resourceType = resourceType;
+  }
+
+  private String type;
+  public void setType(String type) {
+    this.type = type;
+  }
+
+  private String running;
+  public void setRunning(String running) {
+    this.running = running;
+  }
+
+  private String[] search;
+  public void setSearch(String[] search) {
+    this.search = new String[search.length];
+    System.arraycopy(search, 0, this.search, 0, search.length);
+  }
+
+  private String lookup;
+  public void setLookup(String lookup) {
+    this.lookup = lookup;
+  }
+
+  private String fingerprint;
+  public void setFingerprint(String fingerprint) {
+    this.fingerprint = fingerprint;
+  }
+
+  private String country;
+  public void setCountry(String country) {
+    this.country = country;
+  }
+
+  private String as;
+  public void setAs(String as) {
+    this.as = as;
+  }
+
+  private String flag;
+  public void setFlag(String flag) {
+    this.flag = flag;
+  }
+
+  private String[] contact;
+  public void setContact(String[] contact) {
+    this.contact = new String[contact.length];
+    System.arraycopy(contact, 0, this.contact, 0, contact.length);
+  }
+
+  private String[] order;
+  public void setOrder(String[] order) {
+    this.order = new String[order.length];
+    System.arraycopy(order, 0, this.order, 0, order.length);
+  }
+
+  private String offset;
+  public void setOffset(String offset) {
+    this.offset = offset;
+  }
+
+  private String limit;
+  public void setLimit(String limit) {
+    this.limit = limit;
+  }
+
+  private int[] firstSeenDays;
+  public void setFirstSeenDays(int[] firstSeenDays) {
+    this.firstSeenDays = new int[firstSeenDays.length];
+    System.arraycopy(firstSeenDays, 0, this.firstSeenDays, 0,
+        firstSeenDays.length);
+  }
+
+  private int[] lastSeenDays;
+  public void setLastSeenDays(int[] lastSeenDays) {
+    this.lastSeenDays = new int[lastSeenDays.length];
+    System.arraycopy(lastSeenDays, 0, this.lastSeenDays, 0,
+        lastSeenDays.length);
+  }
+
+  private String family;
+  public void setFamily(String family) {
+    this.family = family;
+  }
+
+  private Map<String, SummaryDocument> filteredRelays =
+      new HashMap<String, SummaryDocument>();
+
+  private Map<String, SummaryDocument> filteredBridges =
+      new HashMap<String, SummaryDocument>();
+
+  public void handleRequest() {
+    this.filteredRelays.putAll(
+        this.nodeIndex.getRelayFingerprintSummaryLines());
+    this.filteredBridges.putAll(
+        this.nodeIndex.getBridgeFingerprintSummaryLines());
+    this.filterByResourceType();
+    this.filterByType();
+    this.filterByRunning();
+    this.filterBySearchTerms();
+    this.filterByLookup();
+    this.filterByFingerprint();
+    this.filterByCountryCode();
+    this.filterByASNumber();
+    this.filterByFlag();
+    this.filterNodesByFirstSeenDays();
+    this.filterNodesByLastSeenDays();
+    this.filterByContact();
+    this.filterByFamily();
+    this.order();
+    this.offset();
+    this.limit();
+  }
+
+
+  private void filterByResourceType() {
+    if (this.resourceType.equals("clients")) {
+      this.filteredRelays.clear();
+    }
+    if (this.resourceType.equals("weights")) {
+      this.filteredBridges.clear();
+    }
+  }
+
+  private void filterByType() {
+    if (this.type == null) {
+      return;
+    } else if (this.type.equals("relay")) {
+      this.filteredBridges.clear();
+    } else {
+      this.filteredRelays.clear();
+    }
+  }
+
+  private void filterByRunning() {
+    if (this.running == null) {
+      return;
+    }
+    boolean runningRequested = this.running.equals("true");
+    Set<String> removeRelays = new HashSet<String>();
+    for (Map.Entry<String, SummaryDocument> e :
+        filteredRelays.entrySet()) {
+      if (e.getValue().isRunning() != runningRequested) {
+        removeRelays.add(e.getKey());
+      }
+    }
+    for (String fingerprint : removeRelays) {
+      this.filteredRelays.remove(fingerprint);
+    }
+    Set<String> removeBridges = new HashSet<String>();
+    for (Map.Entry<String, SummaryDocument> e :
+        filteredBridges.entrySet()) {
+      if (e.getValue().isRunning() != runningRequested) {
+        removeBridges.add(e.getKey());
+      }
+    }
+    for (String fingerprint : removeBridges) {
+      this.filteredBridges.remove(fingerprint);
+    }
+  }
+
+  private void filterBySearchTerms() {
+    if (this.search == null) {
+      return;
+    }
+    for (String searchTerm : this.search) {
+      filterBySearchTerm(searchTerm);
+    }
+  }
+
+  private void filterBySearchTerm(String searchTerm) {
+    Set<String> removeRelays = new HashSet<String>();
+    for (Map.Entry<String, SummaryDocument> e :
+        filteredRelays.entrySet()) {
+      String fingerprint = e.getKey();
+      SummaryDocument entry = e.getValue();
+      boolean lineMatches = false;
+      String nickname = entry.getNickname() != null ?
+          entry.getNickname().toLowerCase() : "unnamed";
+      if (searchTerm.startsWith("$")) {
+        /* Search is for $-prefixed fingerprint. */
+        if (fingerprint.startsWith(
+            searchTerm.substring(1).toUpperCase())) {
+          /* $-prefixed fingerprint matches. */
+          lineMatches = true;
+        }
+      } else if (nickname.contains(searchTerm.toLowerCase())) {
+        /* Nickname matches. */
+        lineMatches = true;
+      } else if (fingerprint.startsWith(searchTerm.toUpperCase())) {
+        /* Non-$-prefixed fingerprint matches. */
+        lineMatches = true;
+      } else {
+        List<String> addresses = entry.getAddresses();
+        for (String address : addresses) {
+          if (address.startsWith(searchTerm.toLowerCase())) {
+            /* Address matches. */
+            lineMatches = true;
+            break;
+          }
+        }
+      }
+      if (!lineMatches) {
+        removeRelays.add(e.getKey());
+      }
+    }
+    for (String fingerprint : removeRelays) {
+      this.filteredRelays.remove(fingerprint);
+    }
+    Set<String> removeBridges = new HashSet<String>();
+    for (Map.Entry<String, SummaryDocument> e :
+        filteredBridges.entrySet()) {
+      String hashedFingerprint = e.getKey();
+      SummaryDocument entry = e.getValue();
+      boolean lineMatches = false;
+      String nickname = entry.getNickname() != null ?
+          entry.getNickname().toLowerCase() : "unnamed";
+      if (searchTerm.startsWith("$")) {
+        /* Search is for $-prefixed hashed fingerprint. */
+        if (hashedFingerprint.startsWith(
+            searchTerm.substring(1).toUpperCase())) {
+          /* $-prefixed hashed fingerprint matches. */
+          lineMatches = true;
+        }
+      } else if (nickname.contains(searchTerm.toLowerCase())) {
+        /* Nickname matches. */
+        lineMatches = true;
+      } else if (hashedFingerprint.startsWith(searchTerm.toUpperCase())) {
+        /* Non-$-prefixed hashed fingerprint matches. */
+        lineMatches = true;
+      }
+      if (!lineMatches) {
+        removeBridges.add(e.getKey());
+      }
+    }
+    for (String fingerprint : removeBridges) {
+      this.filteredBridges.remove(fingerprint);
+    }
+  }
+
+  private void filterByLookup() {
+    if (this.lookup == null) {
+      return;
+    }
+    String fingerprint = this.lookup;
+    SummaryDocument relayLine = this.filteredRelays.get(fingerprint);
+    this.filteredRelays.clear();
+    if (relayLine != null) {
+      this.filteredRelays.put(fingerprint, relayLine);
+    }
+    SummaryDocument bridgeLine = this.filteredBridges.get(fingerprint);
+    this.filteredBridges.clear();
+    if (bridgeLine != null) {
+      this.filteredBridges.put(fingerprint, bridgeLine);
+    }
+  }
+
+  private void filterByFingerprint() {
+    if (this.fingerprint == null) {
+      return;
+    }
+    this.filteredRelays.clear();
+    this.filteredBridges.clear();
+    String fingerprint = this.fingerprint;
+    SummaryDocument entry = this.documentStore.retrieve(
+        SummaryDocument.class, true, fingerprint);
+    if (entry != null) {
+      if (entry.isRelay()) {
+        this.filteredRelays.put(fingerprint, entry);
+      } else {
+        this.filteredBridges.put(fingerprint, entry);
+      }
+    }
+  }
+
+  private void filterByCountryCode() {
+    if (this.country == null) {
+      return;
+    }
+    String countryCode = this.country.toLowerCase();
+    if (!this.nodeIndex.getRelaysByCountryCode().containsKey(
+        countryCode)) {
+      this.filteredRelays.clear();
+    } else {
+      Set<String> relaysWithCountryCode =
+          this.nodeIndex.getRelaysByCountryCode().get(countryCode);
+      Set<String> removeRelays = new HashSet<String>();
+      for (String fingerprint : this.filteredRelays.keySet()) {
+        if (!relaysWithCountryCode.contains(fingerprint)) {
+          removeRelays.add(fingerprint);
+        }
+      }
+      for (String fingerprint : removeRelays) {
+        this.filteredRelays.remove(fingerprint);
+      }
+    }
+    this.filteredBridges.clear();
+  }
+
+  private void filterByASNumber() {
+    if (this.as == null) {
+      return;
+    }
+    String aSNumber = this.as.toUpperCase();
+    if (!aSNumber.startsWith("AS")) {
+      aSNumber = "AS" + aSNumber;
+    }
+    if (!this.nodeIndex.getRelaysByASNumber().containsKey(aSNumber)) {
+      this.filteredRelays.clear();
+    } else {
+      Set<String> relaysWithASNumber =
+          this.nodeIndex.getRelaysByASNumber().get(aSNumber);
+      Set<String> removeRelays = new HashSet<String>();
+      for (String fingerprint : this.filteredRelays.keySet()) {
+        if (!relaysWithASNumber.contains(fingerprint)) {
+          removeRelays.add(fingerprint);
+        }
+      }
+      for (String fingerprint : removeRelays) {
+        this.filteredRelays.remove(fingerprint);
+      }
+    }
+    this.filteredBridges.clear();
+  }
+
+  private void filterByFlag() {
+    if (this.flag == null) {
+      return;
+    }
+    String flag = this.flag.toLowerCase();
+    if (!this.nodeIndex.getRelaysByFlag().containsKey(flag)) {
+      this.filteredRelays.clear();
+    } else {
+      Set<String> relaysWithFlag = this.nodeIndex.getRelaysByFlag().get(
+          flag);
+      Set<String> removeRelays = new HashSet<String>();
+      for (String fingerprint : this.filteredRelays.keySet()) {
+        if (!relaysWithFlag.contains(fingerprint)) {
+          removeRelays.add(fingerprint);
+        }
+      }
+      for (String fingerprint : removeRelays) {
+        this.filteredRelays.remove(fingerprint);
+      }
+    }
+    if (!this.nodeIndex.getBridgesByFlag().containsKey(flag)) {
+      this.filteredBridges.clear();
+    } else {
+      Set<String> bridgesWithFlag = this.nodeIndex.getBridgesByFlag().get(
+          flag);
+      Set<String> removeBridges = new HashSet<String>();
+      for (String fingerprint : this.filteredBridges.keySet()) {
+        if (!bridgesWithFlag.contains(fingerprint)) {
+          removeBridges.add(fingerprint);
+        }
+      }
+      for (String fingerprint : removeBridges) {
+        this.filteredBridges.remove(fingerprint);
+      }
+    }
+  }
+
+  private void filterNodesByFirstSeenDays() {
+    if (this.firstSeenDays == null) {
+      return;
+    }
+    filterNodesByDays(this.filteredRelays,
+        this.nodeIndex.getRelaysByFirstSeenDays(), this.firstSeenDays);
+    filterNodesByDays(this.filteredBridges,
+        this.nodeIndex.getBridgesByFirstSeenDays(), this.firstSeenDays);
+  }
+
+  private void filterNodesByLastSeenDays() {
+    if (this.lastSeenDays == null) {
+      return;
+    }
+    filterNodesByDays(this.filteredRelays,
+        this.nodeIndex.getRelaysByLastSeenDays(), this.lastSeenDays);
+    filterNodesByDays(this.filteredBridges,
+        this.nodeIndex.getBridgesByLastSeenDays(), this.lastSeenDays);
+  }
+
+  private void filterNodesByDays(
+      Map<String, SummaryDocument> filteredNodes,
+      SortedMap<Integer, Set<String>> nodesByDays, int[] days) {
+    Set<String> removeNodes = new HashSet<String>();
+    for (Set<String> nodes : nodesByDays.headMap(days[0]).values()) {
+      removeNodes.addAll(nodes);
+    }
+    if (days[1] < Integer.MAX_VALUE) {
+      for (Set<String> nodes :
+          nodesByDays.tailMap(days[1] + 1).values()) {
+        removeNodes.addAll(nodes);
+      }
+    }
+    for (String fingerprint : removeNodes) {
+      filteredNodes.remove(fingerprint);
+    }
+  }
+
+  private void filterByContact() {
+    if (this.contact == null) {
+      return;
+    }
+    Set<String> removeRelays = new HashSet<String>();
+    for (Map.Entry<String, Set<String>> e :
+        this.nodeIndex.getRelaysByContact().entrySet()) {
+      String contact = e.getKey();
+      for (String contactPart : this.contact) {
+        if (contact == null ||
+            !contact.contains(contactPart.toLowerCase())) {
+          removeRelays.addAll(e.getValue());
+          break;
+        }
+      }
+    }
+    for (String fingerprint : removeRelays) {
+      this.filteredRelays.remove(fingerprint);
+    }
+    this.filteredBridges.clear();
+  }
+
+  private void filterByFamily() {
+    if (this.family == null) {
+      return;
+    }
+    Set<String> removeRelays = new HashSet<String>(
+        this.filteredRelays.keySet());
+    removeRelays.remove(this.family);
+    if (this.nodeIndex.getRelaysByFamily().containsKey(this.family)) {
+      removeRelays.removeAll(this.nodeIndex.getRelaysByFamily().
+          get(this.family));
+    }
+    for (String fingerprint : removeRelays) {
+      this.filteredRelays.remove(fingerprint);
+    }
+    this.filteredBridges.clear();
+  }
+
+  private void order() {
+    if (this.order != null && this.order.length == 1) {
+      List<String> orderBy = new ArrayList<String>(
+          this.nodeIndex.getRelaysByConsensusWeight());
+      if (this.order[0].startsWith("-")) {
+        Collections.reverse(orderBy);
+      }
+      for (String relay : orderBy) {
+        if (this.filteredRelays.containsKey(relay) &&
+            !this.orderedRelays.contains(filteredRelays.get(relay))) {
+          this.orderedRelays.add(this.filteredRelays.remove(relay));
+        }
+      }
+      for (String relay : this.filteredRelays.keySet()) {
+        if (!this.orderedRelays.contains(this.filteredRelays.get(relay))) {
+          this.orderedRelays.add(this.filteredRelays.remove(relay));
+        }
+      }
+      Set<SummaryDocument> uniqueBridges = new HashSet<SummaryDocument>(
+          this.filteredBridges.values());
+      this.orderedBridges.addAll(uniqueBridges);
+    } else {
+      Set<SummaryDocument> uniqueRelays = new HashSet<SummaryDocument>(
+          this.filteredRelays.values());
+      this.orderedRelays.addAll(uniqueRelays);
+      Set<SummaryDocument> uniqueBridges = new HashSet<SummaryDocument>(
+          this.filteredBridges.values());
+      this.orderedBridges.addAll(uniqueBridges);
+    }
+  }
+
+  private void offset() {
+    if (this.offset == null) {
+      return;
+    }
+    int offsetValue = Integer.parseInt(this.offset);
+    while (offsetValue-- > 0 &&
+        (!this.orderedRelays.isEmpty() ||
+        !this.orderedBridges.isEmpty())) {
+      if (!this.orderedRelays.isEmpty()) {
+        this.orderedRelays.remove(0);
+      } else {
+        this.orderedBridges.remove(0);
+      }
+    }
+  }
+
+  private void limit() {
+    if (this.limit == null) {
+      return;
+    }
+    int limitValue = Integer.parseInt(this.limit);
+    while (!this.orderedRelays.isEmpty() &&
+        limitValue < this.orderedRelays.size()) {
+      this.orderedRelays.remove(this.orderedRelays.size() - 1);
+    }
+    limitValue -= this.orderedRelays.size();
+    while (!this.orderedBridges.isEmpty() &&
+        limitValue < this.orderedBridges.size()) {
+      this.orderedBridges.remove(this.orderedBridges.size() - 1);
+    }
+  }
+
+  private List<SummaryDocument> orderedRelays =
+      new ArrayList<SummaryDocument>();
+  public List<SummaryDocument> getOrderedRelays() {
+    return this.orderedRelays;
+  }
+
+  private List<SummaryDocument> orderedBridges =
+      new ArrayList<SummaryDocument>();
+  public List<SummaryDocument> getOrderedBridges() {
+    return this.orderedBridges;
+  }
+
+  public String getRelaysPublishedString() {
+    return this.nodeIndex.getRelaysPublishedString();
+  }
+
+  public String getBridgesPublishedString() {
+    return this.nodeIndex.getBridgesPublishedString();
+  }
+}
diff --git a/src/org/torproject/onionoo/server/ResourceServlet.java b/src/org/torproject/onionoo/server/ResourceServlet.java
new file mode 100644
index 0000000..1e05d12
--- /dev/null
+++ b/src/org/torproject/onionoo/server/ResourceServlet.java
@@ -0,0 +1,451 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.server;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+
+public class ResourceServlet extends HttpServlet {
+
+  private static final long serialVersionUID = 7236658979947465319L;
+
+  private boolean maintenanceMode = false;
+
+  /* Called by servlet container, not by test class. */
+  public void init(ServletConfig config) throws ServletException {
+    super.init(config);
+    this.maintenanceMode =
+        config.getInitParameter("maintenance") != null &&
+        config.getInitParameter("maintenance").equals("1");
+  }
+
+  public long getLastModified(HttpServletRequest request) {
+    if (this.maintenanceMode) {
+      return super.getLastModified(request);
+    } else {
+      return ApplicationFactory.getNodeIndexer().getLastIndexed(
+          DateTimeHelper.TEN_SECONDS);
+    }
+  }
+
+  public static class HttpServletRequestWrapper {
+    private HttpServletRequest request;
+    protected HttpServletRequestWrapper(HttpServletRequest request) {
+      this.request = request;
+    }
+    protected String getRequestURI() {
+      return this.request.getRequestURI();
+    }
+    @SuppressWarnings("rawtypes")
+    protected Map getParameterMap() {
+      return this.request.getParameterMap();
+    }
+    protected String[] getParameterValues(String parameterKey) {
+      return this.request.getParameterValues(parameterKey);
+    }
+  }
+
+  public static class HttpServletResponseWrapper {
+    private HttpServletResponse response = null;
+    protected HttpServletResponseWrapper(HttpServletResponse response) {
+      this.response = response;
+    }
+    protected void sendError(int errorStatusCode) throws IOException {
+      this.response.sendError(errorStatusCode);
+    }
+    protected void setHeader(String headerName, String headerValue) {
+      this.response.setHeader(headerName, headerValue);
+    }
+    protected void setContentType(String contentType) {
+      this.response.setContentType(contentType);
+    }
+    protected void setCharacterEncoding(String characterEncoding) {
+      this.response.setCharacterEncoding(characterEncoding);
+    }
+    protected PrintWriter getWriter() throws IOException {
+      return this.response.getWriter();
+    }
+  }
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+    HttpServletRequestWrapper requestWrapper =
+        new HttpServletRequestWrapper(request);
+    HttpServletResponseWrapper responseWrapper =
+        new HttpServletResponseWrapper(response);
+    this.doGet(requestWrapper, responseWrapper);
+  }
+
+  public void doGet(HttpServletRequestWrapper request,
+      HttpServletResponseWrapper response) throws IOException {
+
+    if (this.maintenanceMode) {
+      response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
+      return;
+    }
+
+    if (ApplicationFactory.getNodeIndexer().getLastIndexed(
+        DateTimeHelper.TEN_SECONDS) + DateTimeHelper.SIX_HOURS
+        < ApplicationFactory.getTime().currentTimeMillis()) {
+      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      return;
+    }
+
+    NodeIndex nodeIndex = ApplicationFactory.getNodeIndexer().
+        getLatestNodeIndex(DateTimeHelper.TEN_SECONDS);
+    if (nodeIndex == null) {
+      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      return;
+    }
+
+    String uri = request.getRequestURI();
+    if (uri.startsWith("/onionoo/")) {
+      uri = uri.substring("/onionoo".length());
+    }
+    String resourceType = null;
+    if (uri.startsWith("/summary")) {
+      resourceType = "summary";
+    } else if (uri.startsWith("/details")) {
+      resourceType = "details";
+    } else if (uri.startsWith("/bandwidth")) {
+      resourceType = "bandwidth";
+    } else if (uri.startsWith("/weights")) {
+      resourceType = "weights";
+    } else if (uri.startsWith("/clients")) {
+      resourceType = "clients";
+    } else if (uri.startsWith("/uptime")) {
+      resourceType = "uptime";
+    } else {
+      response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+      return;
+    }
+
+    RequestHandler rh = new RequestHandler(nodeIndex);
+    rh.setResourceType(resourceType);
+
+    /* Extract parameters either from the old-style URI or from request
+     * parameters. */
+    Map<String, String> parameterMap = new HashMap<String, String>();
+    for (Object parameterKey : request.getParameterMap().keySet()) {
+      String[] parameterValues =
+          request.getParameterValues((String) parameterKey);
+      parameterMap.put((String) parameterKey, parameterValues[0]);
+    }
+
+    /* Make sure that the request doesn't contain any unknown
+     * parameters. */
+    Set<String> knownParameters = new HashSet<String>(Arrays.asList((
+        "type,running,search,lookup,fingerprint,country,as,flag,"
+        + "first_seen_days,last_seen_days,contact,order,limit,offset,"
+        + "fields,family").split(",")));
+    for (String parameterKey : parameterMap.keySet()) {
+      if (!knownParameters.contains(parameterKey)) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+    }
+
+    /* Filter relays and bridges matching the request. */
+    if (parameterMap.containsKey("type")) {
+      String typeParameterValue = parameterMap.get("type").toLowerCase();
+      boolean relaysRequested = true;
+      if (typeParameterValue.equals("bridge")) {
+        relaysRequested = false;
+      } else if (!typeParameterValue.equals("relay")) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setType(relaysRequested ? "relay" : "bridge");
+    }
+    if (parameterMap.containsKey("running")) {
+      String runningParameterValue =
+          parameterMap.get("running").toLowerCase();
+      boolean runningRequested = true;
+      if (runningParameterValue.equals("false")) {
+        runningRequested = false;
+      } else if (!runningParameterValue.equals("true")) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setRunning(runningRequested ? "true" : "false");
+    }
+    if (parameterMap.containsKey("search")) {
+      String[] searchTerms = this.parseSearchParameters(
+          parameterMap.get("search"));
+      if (searchTerms == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setSearch(searchTerms);
+    }
+    if (parameterMap.containsKey("lookup")) {
+      String lookupParameter = this.parseFingerprintParameter(
+          parameterMap.get("lookup"));
+      if (lookupParameter == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      String fingerprint = lookupParameter.toUpperCase();
+      rh.setLookup(fingerprint);
+    }
+    if (parameterMap.containsKey("fingerprint")) {
+      String fingerprintParameter = this.parseFingerprintParameter(
+          parameterMap.get("fingerprint"));
+      if (fingerprintParameter == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      String fingerprint = fingerprintParameter.toUpperCase();
+      rh.setFingerprint(fingerprint);
+    }
+    if (parameterMap.containsKey("country")) {
+      String countryCodeParameter = this.parseCountryCodeParameter(
+          parameterMap.get("country"));
+      if (countryCodeParameter == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setCountry(countryCodeParameter);
+    }
+    if (parameterMap.containsKey("as")) {
+      String aSNumberParameter = this.parseASNumberParameter(
+          parameterMap.get("as"));
+      if (aSNumberParameter == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setAs(aSNumberParameter);
+    }
+    if (parameterMap.containsKey("flag")) {
+      String flagParameter = this.parseFlagParameter(
+          parameterMap.get("flag"));
+      if (flagParameter == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setFlag(flagParameter);
+    }
+    if (parameterMap.containsKey("first_seen_days")) {
+      int[] days = this.parseDaysParameter(
+          parameterMap.get("first_seen_days"));
+      if (days == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setFirstSeenDays(days);
+    }
+    if (parameterMap.containsKey("last_seen_days")) {
+      int[] days = this.parseDaysParameter(
+          parameterMap.get("last_seen_days"));
+      if (days == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setLastSeenDays(days);
+    }
+    if (parameterMap.containsKey("contact")) {
+      String[] contactParts = this.parseContactParameter(
+          parameterMap.get("contact"));
+      if (contactParts == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setContact(contactParts);
+    }
+    if (parameterMap.containsKey("order")) {
+      String orderParameter = parameterMap.get("order").toLowerCase();
+      String orderByField = orderParameter;
+      if (orderByField.startsWith("-")) {
+        orderByField = orderByField.substring(1);
+      }
+      if (!orderByField.equals("consensus_weight")) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setOrder(new String[] { orderParameter });
+    }
+    if (parameterMap.containsKey("offset")) {
+      String offsetParameter = parameterMap.get("offset");
+      if (offsetParameter.length() > 6) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      try {
+        Integer.parseInt(offsetParameter);
+      } catch (NumberFormatException e) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setOffset(offsetParameter);
+    }
+    if (parameterMap.containsKey("limit")) {
+      String limitParameter = parameterMap.get("limit");
+      if (limitParameter.length() > 6) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      try {
+        Integer.parseInt(limitParameter);
+      } catch (NumberFormatException e) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rh.setLimit(limitParameter);
+    }
+    if (parameterMap.containsKey("family")) {
+      String familyParameter = this.parseFingerprintParameter(
+          parameterMap.get("family"));
+      if (familyParameter == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      String family = familyParameter.toUpperCase();
+      rh.setFamily(family);
+    }
+    rh.handleRequest();
+
+    ResponseBuilder rb = new ResponseBuilder();
+    rb.setResourceType(resourceType);
+    rb.setRelaysPublishedString(rh.getRelaysPublishedString());
+    rb.setBridgesPublishedString(rh.getBridgesPublishedString());
+    rb.setOrderedRelays(rh.getOrderedRelays());
+    rb.setOrderedBridges(rh.getOrderedBridges());
+    String[] fields = null;
+    if (parameterMap.containsKey("fields")) {
+      fields = this.parseFieldsParameter(parameterMap.get("fields"));
+      if (fields == null) {
+        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+        return;
+      }
+      rb.setFields(fields);
+    }
+
+    response.setHeader("Access-Control-Allow-Origin", "*");
+    response.setContentType("application/json");
+    response.setCharacterEncoding("utf-8");
+    PrintWriter pw = response.getWriter();
+    rb.buildResponse(pw);
+    pw.flush();
+    pw.close();
+  }
+
+  private static Pattern searchParameterPattern =
+      Pattern.compile("^\\$?[0-9a-fA-F]{1,40}$|" /* Fingerprint. */
+      + "^[0-9a-zA-Z\\.]{1,19}$|" /* Nickname or IPv4 address. */
+      + "^\\[[0-9a-fA-F:\\.]{1,39}\\]?$"); /* IPv6 address. */
+  private String[] parseSearchParameters(String parameter) {
+    String[] searchParameters;
+    if (parameter.contains(" ")) {
+      searchParameters = parameter.split(" ");
+    } else {
+      searchParameters = new String[] { parameter };
+    }
+    for (String searchParameter : searchParameters) {
+      if (!searchParameterPattern.matcher(searchParameter).matches()) {
+        return null;
+      }
+    }
+    return searchParameters;
+  }
+
+  private static Pattern fingerprintParameterPattern =
+      Pattern.compile("^[0-9a-zA-Z]{1,40}$");
+  private String parseFingerprintParameter(String parameter) {
+    if (!fingerprintParameterPattern.matcher(parameter).matches()) {
+      return null;
+    }
+    if (parameter.length() != 40) {
+      return null;
+    }
+    return parameter;
+  }
+
+  private static Pattern countryCodeParameterPattern =
+      Pattern.compile("^[0-9a-zA-Z]{2}$");
+  private String parseCountryCodeParameter(String parameter) {
+    if (!countryCodeParameterPattern.matcher(parameter).matches()) {
+      return null;
+    }
+    return parameter;
+  }
+
+  private static Pattern aSNumberParameterPattern =
+      Pattern.compile("^[asAS]{0,2}[0-9]{1,10}$");
+  private String parseASNumberParameter(String parameter) {
+    if (!aSNumberParameterPattern.matcher(parameter).matches()) {
+      return null;
+    }
+    return parameter;
+  }
+
+  private static Pattern flagPattern =
+      Pattern.compile("^[a-zA-Z0-9]{1,20}$");
+  private String parseFlagParameter(String parameter) {
+    if (!flagPattern.matcher(parameter).matches()) {
+      return null;
+    }
+    return parameter;
+  }
+
+  private static Pattern daysPattern = Pattern.compile("^[0-9-]{1,10}$");
+  private int[] parseDaysParameter(String parameter) {
+    if (!daysPattern.matcher(parameter).matches()) {
+      return null;
+    }
+    int x = 0, y = Integer.MAX_VALUE;
+    try {
+      if (!parameter.contains("-")) {
+        x = Integer.parseInt(parameter);
+        y = x;
+      } else {
+        String[] parts = parameter.split("-", 2);
+        if (parts[0].length() > 0) {
+          x = Integer.parseInt(parts[0]);
+        }
+        if (parts.length > 1 && parts[1].length() > 0) {
+          y = Integer.parseInt(parts[1]);
+        }
+      }
+    } catch (NumberFormatException e) {
+      return null;
+    }
+    if (x > y) {
+      return null;
+    }
+    return new int[] { x, y };
+  }
+
+  private String[] parseContactParameter(String parameter) {
+    for (char c : parameter.toCharArray()) {
+      if (c < 32 || c >= 127) {
+        return null;
+      }
+    }
+    return parameter.split(" ");
+  }
+
+  private static Pattern fieldsParameterPattern =
+      Pattern.compile("^[0-9a-zA-Z_,]*$");
+  private String[] parseFieldsParameter(String parameter) {
+    if (!fieldsParameterPattern.matcher(parameter).matches()) {
+      return null;
+    }
+    return parameter.split(",");
+  }
+}
+
diff --git a/src/org/torproject/onionoo/server/ResponseBuilder.java b/src/org/torproject/onionoo/server/ResponseBuilder.java
new file mode 100644
index 0000000..161692c
--- /dev/null
+++ b/src/org/torproject/onionoo/server/ResponseBuilder.java
@@ -0,0 +1,320 @@
+/* Copyright 2011--2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.server;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.torproject.onionoo.docs.BandwidthDocument;
+import org.torproject.onionoo.docs.ClientsDocument;
+import org.torproject.onionoo.docs.DetailsDocument;
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.docs.SummaryDocument;
+import org.torproject.onionoo.docs.UptimeDocument;
+import org.torproject.onionoo.docs.WeightsDocument;
+import org.torproject.onionoo.util.ApplicationFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+public class ResponseBuilder {
+
+  private DocumentStore documentStore;
+
+  public ResponseBuilder() {
+    this.documentStore = ApplicationFactory.getDocumentStore();
+  }
+
+  private String resourceType;
+  public void setResourceType(String resourceType) {
+    this.resourceType = resourceType;
+  }
+
+  private String relaysPublishedString;
+  public void setRelaysPublishedString(String relaysPublishedString) {
+    this.relaysPublishedString = relaysPublishedString;
+  }
+
+  private String bridgesPublishedString;
+  public void setBridgesPublishedString(String bridgesPublishedString) {
+    this.bridgesPublishedString = bridgesPublishedString;
+  }
+
+  private List<SummaryDocument> orderedRelays =
+      new ArrayList<SummaryDocument>();
+  public void setOrderedRelays(List<SummaryDocument> orderedRelays) {
+    this.orderedRelays = orderedRelays;
+  }
+
+  private List<SummaryDocument> orderedBridges =
+      new ArrayList<SummaryDocument>();
+  public void setOrderedBridges(List<SummaryDocument> orderedBridges) {
+    this.orderedBridges = orderedBridges;
+  }
+
+  private String[] fields;
+  public void setFields(String[] fields) {
+    this.fields = new String[fields.length];
+    System.arraycopy(fields, 0, this.fields, 0, fields.length);
+  }
+
+  public void buildResponse(PrintWriter pw) {
+    writeRelays(orderedRelays, pw);
+    writeBridges(orderedBridges, pw);
+  }
+
+  private void writeRelays(List<SummaryDocument> relays, PrintWriter pw) {
+    pw.write("{\"relays_published\":\"" + relaysPublishedString
+        + "\",\n\"relays\":[");
+    int written = 0;
+    for (SummaryDocument entry : relays) {
+      String lines = this.formatNodeStatus(entry);
+      if (lines.length() > 0) {
+        pw.print((written++ > 0 ? ",\n" : "\n") + lines);
+      }
+    }
+    pw.print("\n],\n");
+  }
+
+  private void writeBridges(List<SummaryDocument> bridges,
+      PrintWriter pw) {
+    pw.write("\"bridges_published\":\"" + bridgesPublishedString
+        + "\",\n\"bridges\":[");
+    int written = 0;
+    for (SummaryDocument entry : bridges) {
+      String lines = this.formatNodeStatus(entry);
+      if (lines.length() > 0) {
+        pw.print((written++ > 0 ? ",\n" : "\n") + lines);
+      }
+    }
+    pw.print("\n]}\n");
+  }
+
+  private String formatNodeStatus(SummaryDocument entry) {
+    if (this.resourceType == null) {
+      return "";
+    } else if (this.resourceType.equals("summary")) {
+      return this.writeSummaryLine(entry);
+    } else if (this.resourceType.equals("details")) {
+      return this.writeDetailsLines(entry);
+    } else if (this.resourceType.equals("bandwidth")) {
+      return this.writeBandwidthLines(entry);
+    } else if (this.resourceType.equals("weights")) {
+      return this.writeWeightsLines(entry);
+    } else if (this.resourceType.equals("clients")) {
+      return this.writeClientsLines(entry);
+    } else if (this.resourceType.equals("uptime")) {
+      return this.writeUptimeLines(entry);
+    } else {
+      return "";
+    }
+  }
+
+  private String writeSummaryLine(SummaryDocument entry) {
+    return entry.isRelay() ? writeRelaySummaryLine(entry)
+        : writeBridgeSummaryLine(entry);
+  }
+
+  private String writeRelaySummaryLine(SummaryDocument entry) {
+    String nickname = !entry.getNickname().equals("Unnamed") ?
+        entry.getNickname() : null;
+    String fingerprint = entry.getFingerprint();
+    String running = entry.isRunning() ? "true" : "false";
+    List<String> addresses = entry.getAddresses();
+    StringBuilder addressesBuilder = new StringBuilder();
+    int written = 0;
+    for (String address : addresses) {
+      addressesBuilder.append((written++ > 0 ? "," : "") + "\""
+          + address.toLowerCase() + "\"");
+    }
+    return String.format("{%s\"f\":\"%s\",\"a\":[%s],\"r\":%s}",
+        (nickname == null ? "" : "\"n\":\"" + nickname + "\","),
+        fingerprint, addressesBuilder.toString(), running);
+  }
+
+  private String writeBridgeSummaryLine(SummaryDocument entry) {
+    String nickname = !entry.getNickname().equals("Unnamed") ?
+        entry.getNickname() : null;
+    String hashedFingerprint = entry.getFingerprint();
+    String running = entry.isRunning() ? "true" : "false";
+    return String.format("{%s\"h\":\"%s\",\"r\":%s}",
+         (nickname == null ? "" : "\"n\":\"" + nickname + "\","),
+         hashedFingerprint, running);
+  }
+
+  private String writeDetailsLines(SummaryDocument entry) {
+    String fingerprint = entry.getFingerprint();
+    if (this.fields != null) {
+      /* TODO Maybe there's a more elegant way (more maintainable, more
+       * efficient, etc.) to implement this? */
+      DetailsDocument detailsDocument = documentStore.retrieve(
+          DetailsDocument.class, true, fingerprint);
+      if (detailsDocument != null) {
+        DetailsDocument dd = new DetailsDocument();
+        for (String field : this.fields) {
+          if (field.equals("nickname")) {
+            dd.setNickname(detailsDocument.getNickname());
+          } else if (field.equals("fingerprint")) {
+            dd.setFingerprint(detailsDocument.getFingerprint());
+          } else if (field.equals("hashed_fingerprint")) {
+            dd.setHashedFingerprint(
+                detailsDocument.getHashedFingerprint());
+          } else if (field.equals("or_addresses")) {
+            dd.setOrAddresses(detailsDocument.getOrAddresses());
+          } else if (field.equals("exit_addresses")) {
+            dd.setExitAddresses(detailsDocument.getExitAddresses());
+          } else if (field.equals("dir_address")) {
+            dd.setDirAddress(detailsDocument.getDirAddress());
+          } else if (field.equals("last_seen")) {
+            dd.setLastSeen(detailsDocument.getLastSeen());
+          } else if (field.equals("last_changed_address_or_port")) {
+            dd.setLastChangedAddressOrPort(
+                detailsDocument.getLastChangedAddressOrPort());
+          } else if (field.equals("first_seen")) {
+            dd.setFirstSeen(detailsDocument.getFirstSeen());
+          } else if (field.equals("running")) {
+            dd.setRunning(detailsDocument.getRunning());
+          } else if (field.equals("flags")) {
+            dd.setFlags(detailsDocument.getFlags());
+          } else if (field.equals("country")) {
+            dd.setCountry(detailsDocument.getCountry());
+          } else if (field.equals("country_name")) {
+            dd.setCountryName(detailsDocument.getCountryName());
+          } else if (field.equals("region_name")) {
+            dd.setRegionName(detailsDocument.getRegionName());
+          } else if (field.equals("city_name")) {
+            dd.setCityName(detailsDocument.getCityName());
+          } else if (field.equals("latitude")) {
+            dd.setLatitude(detailsDocument.getLatitude());
+          } else if (field.equals("longitude")) {
+            dd.setLongitude(detailsDocument.getLongitude());
+          } else if (field.equals("as_number")) {
+            dd.setAsNumber(detailsDocument.getAsNumber());
+          } else if (field.equals("as_name")) {
+            dd.setAsName(detailsDocument.getAsName());
+          } else if (field.equals("consensus_weight")) {
+            dd.setConsensusWeight(detailsDocument.getConsensusWeight());
+          } else if (field.equals("host_name")) {
+            dd.setHostName(detailsDocument.getHostName());
+          } else if (field.equals("last_restarted")) {
+            dd.setLastRestarted(detailsDocument.getLastRestarted());
+          } else if (field.equals("bandwidth_rate")) {
+            dd.setBandwidthRate(detailsDocument.getBandwidthRate());
+          } else if (field.equals("bandwidth_burst")) {
+            dd.setBandwidthBurst(detailsDocument.getBandwidthBurst());
+          } else if (field.equals("observed_bandwidth")) {
+            dd.setObservedBandwidth(
+                detailsDocument.getObservedBandwidth());
+          } else if (field.equals("advertised_bandwidth")) {
+            dd.setAdvertisedBandwidth(
+                detailsDocument.getAdvertisedBandwidth());
+          } else if (field.equals("exit_policy")) {
+            dd.setExitPolicy(detailsDocument.getExitPolicy());
+          } else if (field.equals("exit_policy_summary")) {
+            dd.setExitPolicySummary(
+                detailsDocument.getExitPolicySummary());
+          } else if (field.equals("exit_policy_v6_summary")) {
+            dd.setExitPolicyV6Summary(
+                detailsDocument.getExitPolicyV6Summary());
+          } else if (field.equals("contact")) {
+            dd.setContact(detailsDocument.getContact());
+          } else if (field.equals("platform")) {
+            dd.setPlatform(detailsDocument.getPlatform());
+          } else if (field.equals("family")) {
+            dd.setFamily(detailsDocument.getFamily());
+          } else if (field.equals("advertised_bandwidth_fraction")) {
+            dd.setAdvertisedBandwidthFraction(
+                detailsDocument.getAdvertisedBandwidthFraction());
+          } else if (field.equals("consensus_weight_fraction")) {
+            dd.setConsensusWeightFraction(
+                detailsDocument.getConsensusWeightFraction());
+          } else if (field.equals("guard_probability")) {
+            dd.setGuardProbability(detailsDocument.getGuardProbability());
+          } else if (field.equals("middle_probability")) {
+            dd.setMiddleProbability(
+                detailsDocument.getMiddleProbability());
+          } else if (field.equals("exit_probability")) {
+            dd.setExitProbability(detailsDocument.getExitProbability());
+          } else if (field.equals("recommended_version")) {
+            dd.setRecommendedVersion(
+                detailsDocument.getRecommendedVersion());
+          } else if (field.equals("hibernating")) {
+            dd.setHibernating(detailsDocument.getHibernating());
+          } else if (field.equals("pool_assignment")) {
+            dd.setPoolAssignment(detailsDocument.getPoolAssignment());
+          }
+        }
+        /* Don't escape HTML characters, like < and >, contained in
+         * strings. */
+        Gson gson = new GsonBuilder().disableHtmlEscaping().create();
+        /* Whenever we provide Gson with a string containing an escaped
+         * non-ASCII character like \u00F2, it escapes the \ to \\, which
+         * we need to undo before including the string in a response. */
+        return gson.toJson(dd).replaceAll("\\\\\\\\u", "\\\\u");
+      } else {
+        // TODO We should probably log that we didn't find a details
+        // document that we expected to exist.
+        return "";
+      }
+    } else {
+      DetailsDocument detailsDocument = documentStore.retrieve(
+          DetailsDocument.class, false, fingerprint);
+      if (detailsDocument != null) {
+        return detailsDocument.getDocumentString();
+      } else {
+        // TODO We should probably log that we didn't find a details
+        // document that we expected to exist.
+        return "";
+      }
+    }
+  }
+
+  private String writeBandwidthLines(SummaryDocument entry) {
+    String fingerprint = entry.getFingerprint();
+    BandwidthDocument bandwidthDocument = this.documentStore.retrieve(
+        BandwidthDocument.class, false, fingerprint);
+    if (bandwidthDocument != null &&
+        bandwidthDocument.getDocumentString() != null) {
+      return bandwidthDocument.getDocumentString();
+    } else {
+      return "{\"fingerprint\":\"" + fingerprint.toUpperCase() + "\"}";
+    }
+  }
+
+  private String writeWeightsLines(SummaryDocument entry) {
+    String fingerprint = entry.getFingerprint();
+    WeightsDocument weightsDocument = this.documentStore.retrieve(
+        WeightsDocument.class, false, fingerprint);
+    if (weightsDocument != null &&
+        weightsDocument.getDocumentString() != null) {
+      return weightsDocument.getDocumentString();
+    } else {
+      return "{\"fingerprint\":\"" + fingerprint.toUpperCase() + "\"}";
+    }
+  }
+
+  private String writeClientsLines(SummaryDocument entry) {
+    String fingerprint = entry.getFingerprint();
+    ClientsDocument clientsDocument = this.documentStore.retrieve(
+        ClientsDocument.class, false, fingerprint);
+    if (clientsDocument != null &&
+        clientsDocument.getDocumentString() != null) {
+      return clientsDocument.getDocumentString();
+    } else {
+      return "{\"fingerprint\":\"" + fingerprint.toUpperCase() + "\"}";
+    }
+  }
+
+  private String writeUptimeLines(SummaryDocument entry) {
+    String fingerprint = entry.getFingerprint();
+    UptimeDocument uptimeDocument = this.documentStore.retrieve(
+        UptimeDocument.class, false, fingerprint);
+    if (uptimeDocument != null &&
+        uptimeDocument.getDocumentString() != null) {
+      return uptimeDocument.getDocumentString();
+    } else {
+      return "{\"fingerprint\":\"" + fingerprint.toUpperCase() + "\"}";
+    }
+  }
+}
diff --git a/src/org/torproject/onionoo/updater/BandwidthStatusUpdater.java b/src/org/torproject/onionoo/updater/BandwidthStatusUpdater.java
new file mode 100644
index 0000000..bc7dd74
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/BandwidthStatusUpdater.java
@@ -0,0 +1,149 @@
+/* Copyright 2011--2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.ExtraInfoDescriptor;
+import org.torproject.onionoo.docs.BandwidthStatus;
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+
+public class BandwidthStatusUpdater implements DescriptorListener,
+    StatusUpdater {
+
+  private DescriptorSource descriptorSource;
+
+  private DocumentStore documentStore;
+
+  private long now;
+
+  public BandwidthStatusUpdater() {
+    this.descriptorSource = ApplicationFactory.getDescriptorSource();
+    this.documentStore = ApplicationFactory.getDocumentStore();
+    this.now = ApplicationFactory.getTime().currentTimeMillis();
+    this.registerDescriptorListeners();
+  }
+
+  private void registerDescriptorListeners() {
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.RELAY_EXTRA_INFOS);
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.BRIDGE_EXTRA_INFOS);
+  }
+
+  public void processDescriptor(Descriptor descriptor, boolean relay) {
+    if (descriptor instanceof ExtraInfoDescriptor) {
+      this.parseDescriptor((ExtraInfoDescriptor) descriptor);
+    }
+  }
+
+  public void updateStatuses() {
+    /* Status files are already updated while processing descriptors. */
+  }
+
+  private void parseDescriptor(ExtraInfoDescriptor descriptor) {
+    String fingerprint = descriptor.getFingerprint();
+    BandwidthStatus bandwidthStatus = this.documentStore.retrieve(
+        BandwidthStatus.class, true, fingerprint);
+    if (bandwidthStatus == null) {
+      bandwidthStatus = new BandwidthStatus();
+    }
+    if (descriptor.getWriteHistory() != null) {
+      parseHistoryLine(descriptor.getWriteHistory().getLine(),
+          bandwidthStatus.getWriteHistory());
+    }
+    if (descriptor.getReadHistory() != null) {
+      parseHistoryLine(descriptor.getReadHistory().getLine(),
+          bandwidthStatus.getReadHistory());
+    }
+    this.compressHistory(bandwidthStatus.getWriteHistory());
+    this.compressHistory(bandwidthStatus.getReadHistory());
+    this.documentStore.store(bandwidthStatus, fingerprint);
+  }
+
+  private void parseHistoryLine(String line,
+      SortedMap<Long, long[]> history) {
+    String[] parts = line.split(" ");
+    if (parts.length < 6) {
+      return;
+    }
+    long endMillis = DateTimeHelper.parse(parts[1] + " " + parts[2]);
+    if (endMillis < 0L) {
+      System.err.println("Could not parse timestamp in line '" + line
+          + "'.  Skipping.");
+      return;
+    }
+    long intervalMillis = Long.parseLong(parts[3].substring(1))
+        * DateTimeHelper.ONE_SECOND;
+    String[] values = parts[5].split(",");
+    for (int i = values.length - 1; i >= 0; i--) {
+      long bandwidthValue = Long.parseLong(values[i]);
+      long startMillis = endMillis - intervalMillis;
+      /* TODO Should we first check whether an interval is already
+       * contained in history? */
+      history.put(startMillis, new long[] { startMillis, endMillis,
+          bandwidthValue });
+      endMillis -= intervalMillis;
+    }
+  }
+
+  private void compressHistory(SortedMap<Long, long[]> history) {
+    SortedMap<Long, long[]> uncompressedHistory =
+        new TreeMap<Long, long[]>(history);
+    history.clear();
+    long lastStartMillis = 0L, lastEndMillis = 0L, lastBandwidth = 0L;
+    String lastMonthString = "1970-01";
+    for (long[] v : uncompressedHistory.values()) {
+      long startMillis = v[0], endMillis = v[1], bandwidth = v[2];
+      long intervalLengthMillis;
+      if (this.now - endMillis <= DateTimeHelper.THREE_DAYS) {
+        intervalLengthMillis = DateTimeHelper.FIFTEEN_MINUTES;
+      } else if (this.now - endMillis <= DateTimeHelper.ONE_WEEK) {
+        intervalLengthMillis = DateTimeHelper.ONE_HOUR;
+      } else if (this.now - endMillis <=
+          DateTimeHelper.ROUGHLY_ONE_MONTH) {
+        intervalLengthMillis = DateTimeHelper.FOUR_HOURS;
+      } else if (this.now - endMillis <=
+          DateTimeHelper.ROUGHLY_THREE_MONTHS) {
+        intervalLengthMillis = DateTimeHelper.TWELVE_HOURS;
+      } else if (this.now - endMillis <=
+          DateTimeHelper.ROUGHLY_ONE_YEAR) {
+        intervalLengthMillis = DateTimeHelper.TWO_DAYS;
+      } else {
+        intervalLengthMillis = DateTimeHelper.TEN_DAYS;
+      }
+      String monthString = DateTimeHelper.format(startMillis,
+          DateTimeHelper.ISO_YEARMONTH_FORMAT);
+      if (lastEndMillis == startMillis &&
+          ((lastEndMillis - 1L) / intervalLengthMillis) ==
+          ((endMillis - 1L) / intervalLengthMillis) &&
+          lastMonthString.equals(monthString)) {
+        lastEndMillis = endMillis;
+        lastBandwidth += bandwidth;
+      } else {
+        if (lastStartMillis > 0L) {
+          history.put(lastStartMillis, new long[] { lastStartMillis,
+              lastEndMillis, lastBandwidth });
+        }
+        lastStartMillis = startMillis;
+        lastEndMillis = endMillis;
+        lastBandwidth = bandwidth;
+      }
+      lastMonthString = monthString;
+    }
+    if (lastStartMillis > 0L) {
+      history.put(lastStartMillis, new long[] { lastStartMillis,
+          lastEndMillis, lastBandwidth });
+    }
+  }
+
+  public String getStatsString() {
+    /* TODO Add statistics string. */
+    return null;
+  }
+}
+
diff --git a/src/org/torproject/onionoo/updater/ClientsStatusUpdater.java b/src/org/torproject/onionoo/updater/ClientsStatusUpdater.java
new file mode 100644
index 0000000..79c1060
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/ClientsStatusUpdater.java
@@ -0,0 +1,230 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.ExtraInfoDescriptor;
+import org.torproject.onionoo.docs.ClientsHistory;
+import org.torproject.onionoo.docs.ClientsStatus;
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Logger;
+
+/*
+ * Example extra-info descriptor used as input:
+ *
+ * extra-info ndnop2 DE6397A047ABE5F78B4C87AF725047831B221AAB
+ * dirreq-stats-end 2014-02-16 16:42:11 (86400 s)
+ * dirreq-v3-resp ok=856,not-enough-sigs=0,unavailable=0,not-found=0,
+ *   not-modified=40,busy=0
+ * bridge-stats-end 2014-02-16 16:42:17 (86400 s)
+ * bridge-ips ??=8,in=8,se=8
+ * bridge-ip-versions v4=8,v6=0
+ *
+ * Clients status file produced as intermediate output:
+ *
+ * 2014-02-15 16:42:11 2014-02-16 00:00:00
+ *   259.042 in=86.347,se=86.347  v4=259.042
+ * 2014-02-16 00:00:00 2014-02-16 16:42:11
+ *   592.958 in=197.653,se=197.653  v4=592.958
+ */
+public class ClientsStatusUpdater implements DescriptorListener,
+    StatusUpdater {
+
+  private DescriptorSource descriptorSource;
+
+  private DocumentStore documentStore;
+
+  private long now;
+
+  public ClientsStatusUpdater() {
+    this.descriptorSource = ApplicationFactory.getDescriptorSource();
+    this.documentStore = ApplicationFactory.getDocumentStore();
+    this.now = ApplicationFactory.getTime().currentTimeMillis();
+    this.registerDescriptorListeners();
+  }
+
+  private void registerDescriptorListeners() {
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.BRIDGE_EXTRA_INFOS);
+  }
+
+  public void processDescriptor(Descriptor descriptor, boolean relay) {
+    if (descriptor instanceof ExtraInfoDescriptor && !relay) {
+      this.processBridgeExtraInfoDescriptor(
+          (ExtraInfoDescriptor) descriptor);
+    }
+  }
+
+  private SortedMap<String, SortedSet<ClientsHistory>> newResponses =
+      new TreeMap<String, SortedSet<ClientsHistory>>();
+
+  private void processBridgeExtraInfoDescriptor(
+      ExtraInfoDescriptor descriptor) {
+    long dirreqStatsEndMillis = descriptor.getDirreqStatsEndMillis();
+    long dirreqStatsIntervalLengthMillis =
+        descriptor.getDirreqStatsIntervalLength()
+        * DateTimeHelper.ONE_SECOND;
+    SortedMap<String, Integer> responses = descriptor.getDirreqV3Resp();
+    if (dirreqStatsEndMillis < 0L ||
+        dirreqStatsIntervalLengthMillis != DateTimeHelper.ONE_DAY ||
+        responses == null || !responses.containsKey("ok")) {
+      return;
+    }
+    double okResponses = (double) (responses.get("ok") - 4);
+    if (okResponses < 0.0) {
+      return;
+    }
+    String hashedFingerprint = descriptor.getFingerprint().toUpperCase();
+    long dirreqStatsStartMillis = dirreqStatsEndMillis
+        - dirreqStatsIntervalLengthMillis;
+    long utcBreakMillis = (dirreqStatsEndMillis / DateTimeHelper.ONE_DAY)
+        * DateTimeHelper.ONE_DAY;
+    for (int i = 0; i < 2; i++) {
+      long startMillis = i == 0 ? dirreqStatsStartMillis : utcBreakMillis;
+      long endMillis = i == 0 ? utcBreakMillis : dirreqStatsEndMillis;
+      if (startMillis >= endMillis) {
+        continue;
+      }
+      double totalResponses = okResponses
+          * ((double) (endMillis - startMillis))
+          / ((double) DateTimeHelper.ONE_DAY);
+      SortedMap<String, Double> responsesByCountry =
+          this.weightResponsesWithUniqueIps(totalResponses,
+          descriptor.getBridgeIps(), "??");
+      SortedMap<String, Double> responsesByTransport =
+          this.weightResponsesWithUniqueIps(totalResponses,
+          descriptor.getBridgeIpTransports(), "<??>");
+      SortedMap<String, Double> responsesByVersion =
+          this.weightResponsesWithUniqueIps(totalResponses,
+          descriptor.getBridgeIpVersions(), "");
+      ClientsHistory newResponseHistory = new ClientsHistory(
+          startMillis, endMillis, totalResponses, responsesByCountry,
+          responsesByTransport, responsesByVersion); 
+      if (!this.newResponses.containsKey(hashedFingerprint)) {
+        this.newResponses.put(hashedFingerprint,
+            new TreeSet<ClientsHistory>());
+      }
+      this.newResponses.get(hashedFingerprint).add(
+          newResponseHistory);
+    }
+  }
+
+  private SortedMap<String, Double> weightResponsesWithUniqueIps(
+      double totalResponses, SortedMap<String, Integer> uniqueIps,
+      String omitString) {
+    SortedMap<String, Double> weightedResponses =
+        new TreeMap<String, Double>();
+    int totalUniqueIps = 0;
+    if (uniqueIps != null) {
+      for (Map.Entry<String, Integer> e : uniqueIps.entrySet()) {
+        if (e.getValue() > 4) {
+          totalUniqueIps += e.getValue() - 4;
+        }
+      }
+    }
+    if (totalUniqueIps > 0) {
+      for (Map.Entry<String, Integer> e : uniqueIps.entrySet()) {
+        if (!e.getKey().equals(omitString) && e.getValue() > 4) {
+          weightedResponses.put(e.getKey(),
+              (((double) (e.getValue() - 4)) * totalResponses)
+              / ((double) totalUniqueIps));
+        }
+      }
+    }
+    return weightedResponses;
+  }
+
+  public void updateStatuses() {
+    for (Map.Entry<String, SortedSet<ClientsHistory>> e :
+        this.newResponses.entrySet()) {
+      String hashedFingerprint = e.getKey();
+      ClientsStatus clientsStatus = this.documentStore.retrieve(
+          ClientsStatus.class, true, hashedFingerprint);
+      if (clientsStatus == null) {
+        clientsStatus = new ClientsStatus();
+      }
+      this.addToHistory(clientsStatus, e.getValue());
+      this.compressHistory(clientsStatus);
+      this.documentStore.store(clientsStatus, hashedFingerprint);
+    }
+  }
+
+  private void addToHistory(ClientsStatus clientsStatus,
+      SortedSet<ClientsHistory> newIntervals) {
+    SortedSet<ClientsHistory> history = clientsStatus.getHistory();
+    for (ClientsHistory interval : newIntervals) {
+      if ((history.headSet(interval).isEmpty() ||
+          history.headSet(interval).last().getEndMillis() <=
+          interval.getStartMillis()) &&
+          (history.tailSet(interval).isEmpty() ||
+          history.tailSet(interval).first().getStartMillis() >=
+          interval.getEndMillis())) {
+        history.add(interval);
+      }
+    }
+  }
+
+  private void compressHistory(ClientsStatus clientsStatus) {
+    SortedSet<ClientsHistory> history = clientsStatus.getHistory();
+    SortedSet<ClientsHistory> compressedHistory =
+        new TreeSet<ClientsHistory>();
+    ClientsHistory lastResponses = null;
+    String lastMonthString = "1970-01";
+    for (ClientsHistory responses : history) {
+      long intervalLengthMillis;
+      if (this.now - responses.getEndMillis() <=
+          DateTimeHelper.ROUGHLY_THREE_MONTHS) {
+        intervalLengthMillis = DateTimeHelper.ONE_DAY;
+      } else if (this.now - responses.getEndMillis() <=
+          DateTimeHelper.ROUGHLY_ONE_YEAR) {
+        intervalLengthMillis = DateTimeHelper.TWO_DAYS;
+      } else {
+        intervalLengthMillis = DateTimeHelper.TEN_DAYS;
+      }
+      String monthString = DateTimeHelper.format(
+          responses.getStartMillis(),
+          DateTimeHelper.ISO_YEARMONTH_FORMAT);
+      if (lastResponses != null &&
+          lastResponses.getEndMillis() == responses.getStartMillis() &&
+          ((lastResponses.getEndMillis() - 1L) / intervalLengthMillis) ==
+          ((responses.getEndMillis() - 1L) / intervalLengthMillis) &&
+          lastMonthString.equals(monthString)) {
+        lastResponses.addResponses(responses);
+      } else {
+        if (lastResponses != null) {
+          compressedHistory.add(lastResponses);
+        }
+        lastResponses = responses;
+      }
+      lastMonthString = monthString;
+    }
+    if (lastResponses != null) {
+      compressedHistory.add(lastResponses);
+    }
+    clientsStatus.setHistory(compressedHistory);
+  }
+
+  public String getStatsString() {
+    int newIntervals = 0;
+    for (SortedSet<ClientsHistory> hist : this.newResponses.values()) {
+      newIntervals += hist.size();
+    }
+    StringBuilder sb = new StringBuilder();
+    sb.append("    "
+        + Logger.formatDecimalNumber(newIntervals / 2)
+        + " client statistics processed from extra-info descriptors\n");
+    sb.append("    "
+        + Logger.formatDecimalNumber(this.newResponses.size())
+        + " client status files updated\n");
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/updater/DescriptorListener.java b/src/org/torproject/onionoo/updater/DescriptorListener.java
new file mode 100644
index 0000000..3613879
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/DescriptorListener.java
@@ -0,0 +1,7 @@
+package org.torproject.onionoo.updater;
+
+import org.torproject.descriptor.Descriptor;
+
+public interface DescriptorListener {
+  abstract void processDescriptor(Descriptor descriptor, boolean relay);
+}
\ No newline at end of file
diff --git a/src/org/torproject/onionoo/updater/DescriptorSource.java b/src/org/torproject/onionoo/updater/DescriptorSource.java
new file mode 100644
index 0000000..32fbd2a
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/DescriptorSource.java
@@ -0,0 +1,646 @@
+/* Copyright 2013, 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.zip.GZIPInputStream;
+
+import org.torproject.descriptor.BridgeNetworkStatus;
+import org.torproject.descriptor.BridgePoolAssignment;
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.DescriptorFile;
+import org.torproject.descriptor.DescriptorReader;
+import org.torproject.descriptor.DescriptorSourceFactory;
+import org.torproject.descriptor.ExitList;
+import org.torproject.descriptor.ExitListEntry;
+import org.torproject.descriptor.ExtraInfoDescriptor;
+import org.torproject.descriptor.RelayNetworkStatusConsensus;
+import org.torproject.descriptor.ServerDescriptor;
+import org.torproject.onionoo.util.Logger;
+
+enum DescriptorHistory {
+  RELAY_CONSENSUS_HISTORY,
+  RELAY_SERVER_HISTORY,
+  RELAY_EXTRAINFO_HISTORY,
+  EXIT_LIST_HISTORY,
+  BRIDGE_STATUS_HISTORY,
+  BRIDGE_SERVER_HISTORY,
+  BRIDGE_EXTRAINFO_HISTORY,
+  BRIDGE_POOLASSIGN_HISTORY,
+}
+
+class DescriptorDownloader {
+
+  private final String protocolHostNameResourcePrefix =
+      "https://collector.torproject.org/recent/";;
+
+  private String directory;
+
+  private final File inDir = new File("in/recent");
+
+  public DescriptorDownloader(DescriptorType descriptorType) {
+    switch (descriptorType) {
+    case RELAY_CONSENSUSES:
+      this.directory = "relay-descriptors/consensuses/";
+      break;
+    case RELAY_SERVER_DESCRIPTORS:
+      this.directory = "relay-descriptors/server-descriptors/";
+      break;
+    case RELAY_EXTRA_INFOS:
+      this.directory = "relay-descriptors/extra-infos/";
+      break;
+    case EXIT_LISTS:
+      this.directory = "exit-lists/";
+      break;
+    case BRIDGE_STATUSES:
+      this.directory = "bridge-descriptors/statuses/";
+      break;
+    case BRIDGE_SERVER_DESCRIPTORS:
+      this.directory = "bridge-descriptors/server-descriptors/";
+      break;
+    case BRIDGE_EXTRA_INFOS:
+      this.directory = "bridge-descriptors/extra-infos/";
+      break;
+    case BRIDGE_POOL_ASSIGNMENTS:
+      this.directory = "bridge-pool-assignments/";
+      break;
+    default:
+      System.err.println("Unknown descriptor type.");
+      return;
+    }
+  }
+
+  private SortedSet<String> localFiles = new TreeSet<String>();
+
+  public int statLocalFiles() {
+    File localDirectory = new File(this.inDir, this.directory);
+    if (localDirectory.exists()) {
+      for (File file : localDirectory.listFiles()) {
+        this.localFiles.add(file.getName());
+      }
+    }
+    return this.localFiles.size();
+  }
+
+  private SortedSet<String> remoteFiles = new TreeSet<String>();
+
+  public int fetchRemoteDirectory() {
+    String directoryUrl = this.protocolHostNameResourcePrefix
+        + this.directory;
+    try {
+      URL u = new URL(directoryUrl);
+      HttpURLConnection huc = (HttpURLConnection) u.openConnection();
+      huc.setRequestMethod("GET");
+      huc.connect();
+      if (huc.getResponseCode() != 200) {
+        System.err.println("Could not fetch " + directoryUrl
+            + ": " + huc.getResponseCode() + " "
+            + huc.getResponseMessage() + ".  Skipping.");
+        return 0;
+      }
+      BufferedReader br = new BufferedReader(new InputStreamReader(
+          huc.getInputStream()));
+      String line;
+      while ((line = br.readLine()) != null) {
+        if (!line.trim().startsWith("<tr>") ||
+            !line.contains("<a href=\"")) {
+          continue;
+        }
+        String linePart = line.substring(
+            line.indexOf("<a href=\"") + "<a href=\"".length());
+        if (!linePart.contains("\"")) {
+          continue;
+        }
+        linePart = linePart.substring(0, linePart.indexOf("\""));
+        if (linePart.endsWith("/")) {
+          continue;
+        }
+        this.remoteFiles.add(linePart);
+      }
+      br.close();
+    } catch (IOException e) {
+      System.err.println("Could not fetch or parse " + directoryUrl
+          + ".  Skipping.");
+    }
+    return this.remoteFiles.size();
+  }
+
+  public int fetchRemoteFiles() {
+    int fetchedFiles = 0;
+    for (String remoteFile : this.remoteFiles) {
+      if (this.localFiles.contains(remoteFile)) {
+        continue;
+      }
+      String fileUrl = this.protocolHostNameResourcePrefix
+          + this.directory + remoteFile;
+      File localTempFile = new File(this.inDir, this.directory
+          + remoteFile + ".tmp");
+      File localFile = new File(this.inDir, this.directory + remoteFile);
+      try {
+        localFile.getParentFile().mkdirs();
+        URL u = new URL(fileUrl);
+        HttpURLConnection huc = (HttpURLConnection) u.openConnection();
+        huc.setRequestMethod("GET");
+        huc.addRequestProperty("Accept-Encoding", "gzip");
+        huc.connect();
+        if (huc.getResponseCode() != 200) {
+          System.err.println("Could not fetch " + fileUrl
+              + ": " + huc.getResponseCode() + " "
+              + huc.getResponseMessage() + ".  Skipping.");
+          continue;
+        }
+        long lastModified = huc.getHeaderFieldDate("Last-Modified", -1L);
+        InputStream is;
+        if (huc.getContentEncoding() != null &&
+            huc.getContentEncoding().equalsIgnoreCase("gzip")) {
+          is = new GZIPInputStream(huc.getInputStream());
+        } else {
+          is = huc.getInputStream();
+        }
+        BufferedInputStream bis = new BufferedInputStream(is);
+        BufferedOutputStream bos = new BufferedOutputStream(
+            new FileOutputStream(localTempFile));
+        int len;
+        byte[] data = new byte[1024];
+        while ((len = bis.read(data, 0, 1024)) >= 0) {
+          bos.write(data, 0, len);
+        }
+        bis.close();
+        bos.close();
+        localTempFile.renameTo(localFile);
+        if (lastModified >= 0) {
+          localFile.setLastModified(lastModified);
+        }
+        fetchedFiles++;
+      } catch (IOException e) {
+        System.err.println("Could not fetch or store " + fileUrl
+            + ".  Skipping.");
+      }
+    }
+    return fetchedFiles;
+  }
+
+  public int deleteOldLocalFiles() {
+    int deletedFiles = 0;
+    for (String localFile : this.localFiles) {
+      if (!this.remoteFiles.contains(localFile)) {
+        new File(this.inDir, this.directory + localFile).delete();
+        deletedFiles++;
+      }
+    }
+    return deletedFiles;
+  }
+}
+
+class DescriptorQueue {
+
+  private File inDir;
+
+  private File statusDir;
+
+  private DescriptorReader descriptorReader;
+
+  private File historyFile;
+
+  private Iterator<DescriptorFile> descriptorFiles;
+
+  private List<Descriptor> descriptors;
+
+  private int historySizeBefore;
+  public int getHistorySizeBefore() {
+    return this.historySizeBefore;
+  }
+
+  private int historySizeAfter;
+  public int getHistorySizeAfter() {
+    return this.historySizeAfter;
+  }
+
+  private long returnedDescriptors = 0L;
+  public long getReturnedDescriptors() {
+    return this.returnedDescriptors;
+  }
+
+  private long returnedBytes = 0L;
+  public long getReturnedBytes() {
+    return this.returnedBytes;
+  }
+
+  public DescriptorQueue(File inDir, File statusDir) {
+    this.inDir = inDir;
+    this.statusDir = statusDir;
+    this.descriptorReader =
+        DescriptorSourceFactory.createDescriptorReader();
+  }
+
+  public void addDirectory(DescriptorType descriptorType) {
+    String directoryName = null;
+    switch (descriptorType) {
+    case RELAY_CONSENSUSES:
+      directoryName = "relay-descriptors/consensuses";
+      break;
+    case RELAY_SERVER_DESCRIPTORS:
+      directoryName = "relay-descriptors/server-descriptors";
+      break;
+    case RELAY_EXTRA_INFOS:
+      directoryName = "relay-descriptors/extra-infos";
+      break;
+    case BRIDGE_STATUSES:
+      directoryName = "bridge-descriptors/statuses";
+      break;
+    case BRIDGE_SERVER_DESCRIPTORS:
+      directoryName = "bridge-descriptors/server-descriptors";
+      break;
+    case BRIDGE_EXTRA_INFOS:
+      directoryName = "bridge-descriptors/extra-infos";
+      break;
+    case BRIDGE_POOL_ASSIGNMENTS:
+      directoryName = "bridge-pool-assignments";
+      break;
+    case EXIT_LISTS:
+      directoryName = "exit-lists";
+      break;
+    default:
+      System.err.println("Unknown descriptor type.  Not adding directory "
+          + "to descriptor reader.");
+      return;
+    }
+    File directory = new File(this.inDir, directoryName);
+    if (directory.exists() && directory.isDirectory()) {
+      this.descriptorReader.addDirectory(directory);
+      this.descriptorReader.setMaxDescriptorFilesInQueue(1);
+    } else {
+      System.err.println("Directory " + directory.getAbsolutePath()
+          + " either does not exist or is not a directory.  Not adding "
+          + "to descriptor reader.");
+    }
+  }
+
+  public void readHistoryFile(DescriptorHistory descriptorHistory) {
+    String historyFileName = null;
+    switch (descriptorHistory) {
+    case RELAY_EXTRAINFO_HISTORY:
+      historyFileName = "relay-extrainfo-history";
+      break;
+    case BRIDGE_EXTRAINFO_HISTORY:
+      historyFileName = "bridge-extrainfo-history";
+      break;
+    case EXIT_LIST_HISTORY:
+      historyFileName = "exit-list-history";
+      break;
+    case BRIDGE_POOLASSIGN_HISTORY:
+      historyFileName = "bridge-poolassign-history";
+      break;
+    case RELAY_CONSENSUS_HISTORY:
+      historyFileName = "relay-consensus-history";
+      break;
+    case BRIDGE_STATUS_HISTORY:
+      historyFileName = "bridge-status-history";
+      break;
+    case RELAY_SERVER_HISTORY:
+      historyFileName = "relay-server-history";
+      break;
+    case BRIDGE_SERVER_HISTORY:
+      historyFileName = "bridge-server-history";
+      break;
+    default:
+      System.err.println("Unknown descriptor history.  Not excluding "
+          + "files.");
+      return;
+    }
+    this.historyFile = new File(this.statusDir, historyFileName);
+    if (this.historyFile.exists() && this.historyFile.isFile()) {
+      SortedMap<String, Long> excludedFiles = new TreeMap<String, Long>();
+      try {
+        BufferedReader br = new BufferedReader(new FileReader(
+            this.historyFile));
+        String line;
+        while ((line = br.readLine()) != null) {
+          try {
+            String[] parts = line.split(" ", 2);
+            excludedFiles.put(parts[1], Long.parseLong(parts[0]));
+          } catch (NumberFormatException e) {
+            System.err.println("Illegal line '" + line + "' in parse "
+                + "history.  Skipping line.");
+          }
+        }
+        br.close();
+      } catch (IOException e) {
+        System.err.println("Could not read history file '"
+            + this.historyFile.getAbsolutePath() + "'.  Not excluding "
+            + "descriptors in this execution.");
+        e.printStackTrace();
+        return;
+      }
+      this.historySizeBefore = excludedFiles.size();
+      this.descriptorReader.setExcludedFiles(excludedFiles);
+    }
+  }
+
+  public void writeHistoryFile() {
+    if (this.historyFile == null) {
+      return;
+    }
+    SortedMap<String, Long> excludedAndParsedFiles =
+        new TreeMap<String, Long>();
+    excludedAndParsedFiles.putAll(
+        this.descriptorReader.getExcludedFiles());
+    excludedAndParsedFiles.putAll(this.descriptorReader.getParsedFiles());
+    this.historySizeAfter = excludedAndParsedFiles.size();
+    try {
+      this.historyFile.getParentFile().mkdirs();
+      BufferedWriter bw = new BufferedWriter(new FileWriter(
+          this.historyFile));
+      for (Map.Entry<String, Long> e : excludedAndParsedFiles.entrySet()) {
+        String absolutePath = e.getKey();
+        long lastModifiedMillis = e.getValue();
+        bw.write(String.valueOf(lastModifiedMillis) + " " + absolutePath
+            + "\n");
+      }
+      bw.close();
+    } catch (IOException e) {
+      System.err.println("Could not write history file '"
+          + this.historyFile.getAbsolutePath() + "'.  Not excluding "
+          + "descriptors in next execution.");
+      return;
+    }
+  }
+
+  public Descriptor nextDescriptor() {
+    Descriptor nextDescriptor = null;
+    if (this.descriptorFiles == null) {
+      this.descriptorFiles = this.descriptorReader.readDescriptors();
+    }
+    while (this.descriptors == null && this.descriptorFiles.hasNext()) {
+      DescriptorFile descriptorFile = this.descriptorFiles.next();
+      if (descriptorFile.getException() != null) {
+        System.err.println("Could not parse "
+            + descriptorFile.getFileName());
+        descriptorFile.getException().printStackTrace();
+      }
+      if (descriptorFile.getDescriptors() != null &&
+          !descriptorFile.getDescriptors().isEmpty()) {
+        this.descriptors = descriptorFile.getDescriptors();
+      }
+    }
+    if (this.descriptors != null) {
+      nextDescriptor = this.descriptors.remove(0);
+      this.returnedDescriptors++;
+      this.returnedBytes += nextDescriptor.getRawDescriptorBytes().length;
+      if (this.descriptors.isEmpty()) {
+        this.descriptors = null;
+      }
+    }
+    return nextDescriptor;
+  }
+}
+
+public class DescriptorSource {
+
+  private final File inDir = new File("in/recent");
+
+  private final File statusDir = new File("status");
+
+  private List<DescriptorQueue> descriptorQueues;
+
+  public DescriptorSource() {
+    this.descriptorQueues = new ArrayList<DescriptorQueue>();
+    this.descriptorListeners =
+        new HashMap<DescriptorType, Set<DescriptorListener>>();
+    this.fingerprintListeners =
+        new HashMap<DescriptorType, Set<FingerprintListener>>();
+  }
+
+  private DescriptorQueue getDescriptorQueue(
+      DescriptorType descriptorType,
+      DescriptorHistory descriptorHistory) {
+    DescriptorQueue descriptorQueue = new DescriptorQueue(this.inDir,
+        this.statusDir);
+    descriptorQueue.addDirectory(descriptorType);
+    if (descriptorHistory != null) {
+      descriptorQueue.readHistoryFile(descriptorHistory);
+    }
+    this.descriptorQueues.add(descriptorQueue);
+    return descriptorQueue;
+  }
+
+  private Map<DescriptorType, Set<DescriptorListener>>
+      descriptorListeners;
+
+  private Map<DescriptorType, Set<FingerprintListener>>
+      fingerprintListeners;
+
+  public void registerDescriptorListener(DescriptorListener listener,
+      DescriptorType descriptorType) {
+    if (!this.descriptorListeners.containsKey(descriptorType)) {
+      this.descriptorListeners.put(descriptorType,
+          new HashSet<DescriptorListener>());
+    }
+    this.descriptorListeners.get(descriptorType).add(listener);
+  }
+
+  public void registerFingerprintListener(FingerprintListener listener,
+      DescriptorType descriptorType) {
+    if (!this.fingerprintListeners.containsKey(descriptorType)) {
+      this.fingerprintListeners.put(descriptorType,
+          new HashSet<FingerprintListener>());
+    }
+    this.fingerprintListeners.get(descriptorType).add(listener);
+  }
+
+  public void downloadDescriptors() {
+    for (DescriptorType descriptorType : DescriptorType.values()) {
+      this.downloadDescriptors(descriptorType);
+    }
+  }
+
+  private int localFilesBefore = 0, foundRemoteFiles = 0,
+      downloadedFiles = 0, deletedLocalFiles = 0;
+
+  private void downloadDescriptors(DescriptorType descriptorType) {
+    if (!this.descriptorListeners.containsKey(descriptorType) &&
+        !this.fingerprintListeners.containsKey(descriptorType)) {
+      return;
+    }
+    DescriptorDownloader descriptorDownloader =
+        new DescriptorDownloader(descriptorType);
+    this.localFilesBefore += descriptorDownloader.statLocalFiles();
+    this.foundRemoteFiles +=
+        descriptorDownloader.fetchRemoteDirectory();
+    this.downloadedFiles += descriptorDownloader.fetchRemoteFiles();
+    this.deletedLocalFiles += descriptorDownloader.deleteOldLocalFiles();
+  }
+
+  public void readDescriptors() {
+    /* Careful when changing the order of parsing descriptor types!  The
+     * various status updaters may base assumptions on this order. */
+    this.readDescriptors(DescriptorType.RELAY_SERVER_DESCRIPTORS,
+        DescriptorHistory.RELAY_SERVER_HISTORY, true);
+    this.readDescriptors(DescriptorType.RELAY_EXTRA_INFOS,
+        DescriptorHistory.RELAY_EXTRAINFO_HISTORY, true);
+    this.readDescriptors(DescriptorType.EXIT_LISTS,
+        DescriptorHistory.EXIT_LIST_HISTORY, true);
+    this.readDescriptors(DescriptorType.RELAY_CONSENSUSES,
+        DescriptorHistory.RELAY_CONSENSUS_HISTORY, true);
+    this.readDescriptors(DescriptorType.BRIDGE_SERVER_DESCRIPTORS,
+        DescriptorHistory.BRIDGE_SERVER_HISTORY, false);
+    this.readDescriptors(DescriptorType.BRIDGE_EXTRA_INFOS,
+        DescriptorHistory.BRIDGE_EXTRAINFO_HISTORY, false);
+    this.readDescriptors(DescriptorType.BRIDGE_POOL_ASSIGNMENTS,
+        DescriptorHistory.BRIDGE_POOLASSIGN_HISTORY, false);
+    this.readDescriptors(DescriptorType.BRIDGE_STATUSES,
+        DescriptorHistory.BRIDGE_STATUS_HISTORY, false);
+  }
+
+  private void readDescriptors(DescriptorType descriptorType,
+      DescriptorHistory descriptorHistory, boolean relay) {
+    if (!this.descriptorListeners.containsKey(descriptorType) &&
+        !this.fingerprintListeners.containsKey(descriptorType)) {
+      return;
+    }
+    Set<DescriptorListener> descriptorListeners =
+        this.descriptorListeners.get(descriptorType);
+    Set<FingerprintListener> fingerprintListeners =
+        this.fingerprintListeners.get(descriptorType);
+    DescriptorQueue descriptorQueue = this.getDescriptorQueue(
+        descriptorType, descriptorHistory);
+    Descriptor descriptor;
+    while ((descriptor = descriptorQueue.nextDescriptor()) != null) {
+      for (DescriptorListener descriptorListener : descriptorListeners) {
+        descriptorListener.processDescriptor(descriptor, relay);
+      }
+      if (fingerprintListeners == null) {
+        continue;
+      }
+      SortedSet<String> fingerprints = new TreeSet<String>();
+      if (descriptorType == DescriptorType.RELAY_CONSENSUSES &&
+          descriptor instanceof RelayNetworkStatusConsensus) {
+        fingerprints.addAll(((RelayNetworkStatusConsensus) descriptor).
+            getStatusEntries().keySet());
+      } else if (descriptorType
+          == DescriptorType.RELAY_SERVER_DESCRIPTORS &&
+          descriptor instanceof ServerDescriptor) {
+        fingerprints.add(((ServerDescriptor) descriptor).
+            getFingerprint());
+      } else if (descriptorType == DescriptorType.RELAY_EXTRA_INFOS &&
+          descriptor instanceof ExtraInfoDescriptor) {
+        fingerprints.add(((ExtraInfoDescriptor) descriptor).
+            getFingerprint());
+      } else if (descriptorType == DescriptorType.EXIT_LISTS &&
+          descriptor instanceof ExitList) {
+        for (ExitListEntry entry :
+            ((ExitList) descriptor).getExitListEntries()) {
+          fingerprints.add(entry.getFingerprint());
+        }
+      } else if (descriptorType == DescriptorType.BRIDGE_STATUSES &&
+          descriptor instanceof BridgeNetworkStatus) {
+        fingerprints.addAll(((BridgeNetworkStatus) descriptor).
+            getStatusEntries().keySet());
+      } else if (descriptorType ==
+          DescriptorType.BRIDGE_SERVER_DESCRIPTORS &&
+          descriptor instanceof ServerDescriptor) {
+        fingerprints.add(((ServerDescriptor) descriptor).
+            getFingerprint());
+      } else if (descriptorType == DescriptorType.BRIDGE_EXTRA_INFOS &&
+          descriptor instanceof ExtraInfoDescriptor) {
+        fingerprints.add(((ExtraInfoDescriptor) descriptor).
+            getFingerprint());
+      } else if (descriptorType ==
+          DescriptorType.BRIDGE_POOL_ASSIGNMENTS &&
+          descriptor instanceof BridgePoolAssignment) {
+        fingerprints.addAll(((BridgePoolAssignment) descriptor).
+            getEntries().keySet());
+      }
+      for (FingerprintListener fingerprintListener :
+          fingerprintListeners) {
+        fingerprintListener.processFingerprints(fingerprints, relay);
+      }
+    }
+    switch (descriptorType) {
+    case RELAY_CONSENSUSES:
+      Logger.printStatusTime("Read relay network consensuses");
+      break;
+    case RELAY_SERVER_DESCRIPTORS:
+      Logger.printStatusTime("Read relay server descriptors");
+      break;
+    case RELAY_EXTRA_INFOS:
+      Logger.printStatusTime("Read relay extra-info descriptors");
+      break;
+    case EXIT_LISTS:
+      Logger.printStatusTime("Read exit lists");
+      break;
+    case BRIDGE_STATUSES:
+      Logger.printStatusTime("Read bridge network statuses");
+      break;
+    case BRIDGE_SERVER_DESCRIPTORS:
+      Logger.printStatusTime("Read bridge server descriptors");
+      break;
+    case BRIDGE_EXTRA_INFOS:
+      Logger.printStatusTime("Read bridge extra-info descriptors");
+      break;
+    case BRIDGE_POOL_ASSIGNMENTS:
+      Logger.printStatusTime("Read bridge-pool assignments");
+      break;
+    }
+  }
+
+  public void writeHistoryFiles() {
+    for (DescriptorQueue descriptorQueue : this.descriptorQueues) {
+      descriptorQueue.writeHistoryFile();
+    }
+  }
+
+  public String getStatsString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("    " + this.localFilesBefore + " descriptor files found "
+        + "locally\n");
+    sb.append("    " + this.foundRemoteFiles + " descriptor files found "
+        + "remotely\n");
+    sb.append("    " + this.downloadedFiles + " descriptor files "
+        + "downloaded from remote\n");
+    sb.append("    " + this.deletedLocalFiles + " descriptor files "
+        + "deleted locally\n");
+    sb.append("    " + this.descriptorQueues.size() + " descriptor "
+        + "queues created\n");
+    int historySizeBefore = 0, historySizeAfter = 0;
+    long descriptors = 0L, bytes = 0L;
+    for (DescriptorQueue descriptorQueue : descriptorQueues) {
+      historySizeBefore += descriptorQueue.getHistorySizeBefore();
+      historySizeAfter += descriptorQueue.getHistorySizeAfter();
+      descriptors += descriptorQueue.getReturnedDescriptors();
+      bytes += descriptorQueue.getReturnedBytes();
+    }
+    sb.append("    " + Logger.formatDecimalNumber(historySizeBefore)
+        + " descriptors excluded from this execution\n");
+    sb.append("    " + Logger.formatDecimalNumber(descriptors)
+        + " descriptors provided\n");
+    sb.append("    " + Logger.formatBytes(bytes) + " provided\n");
+    sb.append("    " + Logger.formatDecimalNumber(historySizeAfter)
+        + " descriptors excluded from next execution\n");
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/updater/DescriptorType.java b/src/org/torproject/onionoo/updater/DescriptorType.java
new file mode 100644
index 0000000..41956da
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/DescriptorType.java
@@ -0,0 +1,15 @@
+/* Copyright 2013, 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+public enum DescriptorType {
+  RELAY_CONSENSUSES,
+  RELAY_SERVER_DESCRIPTORS,
+  RELAY_EXTRA_INFOS,
+  EXIT_LISTS,
+  BRIDGE_STATUSES,
+  BRIDGE_SERVER_DESCRIPTORS,
+  BRIDGE_EXTRA_INFOS,
+  BRIDGE_POOL_ASSIGNMENTS,
+}
+
diff --git a/src/org/torproject/onionoo/updater/FingerprintListener.java b/src/org/torproject/onionoo/updater/FingerprintListener.java
new file mode 100644
index 0000000..5e16eae
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/FingerprintListener.java
@@ -0,0 +1,10 @@
+/* Copyright 2013, 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+import java.util.SortedSet;
+
+public interface FingerprintListener {
+  abstract void processFingerprints(SortedSet<String> fingerprints,
+      boolean relay);
+}
\ No newline at end of file
diff --git a/src/org/torproject/onionoo/updater/LookupResult.java b/src/org/torproject/onionoo/updater/LookupResult.java
new file mode 100644
index 0000000..dcf3a2a
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/LookupResult.java
@@ -0,0 +1,70 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+public class LookupResult {
+
+  private String countryCode;
+  public void setCountryCode(String countryCode) {
+    this.countryCode = countryCode;
+  }
+  public String getCountryCode() {
+    return this.countryCode;
+  }
+
+  private String countryName;
+  public void setCountryName(String countryName) {
+    this.countryName = countryName;
+  }
+  public String getCountryName() {
+    return this.countryName;
+  }
+
+  private String regionName;
+  public void setRegionName(String regionName) {
+    this.regionName = regionName;
+  }
+  public String getRegionName() {
+    return this.regionName;
+  }
+
+  private String cityName;
+  public void setCityName(String cityName) {
+    this.cityName = cityName;
+  }
+  public String getCityName() {
+    return this.cityName;
+  }
+
+  private Float latitude;
+  public void setLatitude(Float latitude) {
+    this.latitude = latitude;
+  }
+  public Float getLatitude() {
+    return this.latitude;
+  }
+
+  private Float longitude;
+  public void setLongitude(Float longitude) {
+    this.longitude = longitude;
+  }
+  public Float getLongitude() {
+    return this.longitude;
+  }
+
+  private String asNumber;
+  public void setAsNumber(String asNumber) {
+    this.asNumber = asNumber;
+  }
+  public String getAsNumber() {
+    return this.asNumber;
+  }
+
+  private String asName;
+  public void setAsName(String asName) {
+    this.asName = asName;
+  }
+  public String getAsName() {
+    return this.asName;
+  }
+}
\ No newline at end of file
diff --git a/src/org/torproject/onionoo/updater/LookupService.java b/src/org/torproject/onionoo/updater/LookupService.java
new file mode 100644
index 0000000..b816091
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/LookupService.java
@@ -0,0 +1,343 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.regex.Pattern;
+
+import org.torproject.onionoo.util.Logger;
+
+public class LookupService {
+
+  private File geoipDir;
+  private File geoLite2CityBlocksCsvFile;
+  private File geoLite2CityLocationsCsvFile;
+  private File geoIPASNum2CsvFile;
+  private boolean hasAllFiles = false;
+  public LookupService(File geoipDir) {
+    this.geoipDir = geoipDir;
+    this.findRequiredCsvFiles();
+  }
+
+  /* Make sure we have all required .csv files. */
+  private void findRequiredCsvFiles() {
+    this.geoLite2CityBlocksCsvFile = new File(this.geoipDir,
+        "GeoLite2-City-Blocks.csv");
+    if (!this.geoLite2CityBlocksCsvFile.exists()) {
+      System.err.println("No GeoLite2-City-Blocks.csv file in geoip/.");
+      return;
+    }
+    this.geoLite2CityLocationsCsvFile = new File(this.geoipDir,
+        "GeoLite2-City-Locations.csv");
+    if (!this.geoLite2CityLocationsCsvFile.exists()) {
+      System.err.println("No GeoLite2-City-Locations.csv file in "
+          + "geoip/.");
+      return;
+    }
+    this.geoIPASNum2CsvFile = new File(this.geoipDir, "GeoIPASNum2.csv");
+    if (!this.geoIPASNum2CsvFile.exists()) {
+      System.err.println("No GeoIPASNum2.csv file in geoip/.");
+      return;
+    }
+    this.hasAllFiles = true;
+  }
+
+  private Pattern ipv4Pattern = Pattern.compile("^[0-9\\.]{7,15}$");
+  private long parseAddressString(String addressString) {
+    long addressNumber = -1L;
+    if (ipv4Pattern.matcher(addressString).matches()) {
+      String[] parts = addressString.split("\\.", 4);
+      if (parts.length == 4) {
+        addressNumber = 0L;
+        for (int i = 0; i < 4; i++) {
+          addressNumber *= 256L;
+          int octetValue = -1;
+          try {
+            octetValue = Integer.parseInt(parts[i]);
+          } catch (NumberFormatException e) {
+          }
+          if (octetValue < 0 || octetValue > 255) {
+            addressNumber = -1L;
+            break;
+          }
+          addressNumber += octetValue;
+        }
+      }
+    }
+    return addressNumber;
+  }
+
+  public SortedMap<String, LookupResult> lookup(
+      SortedSet<String> addressStrings) {
+
+    SortedMap<String, LookupResult> lookupResults =
+        new TreeMap<String, LookupResult>();
+
+    if (!this.hasAllFiles) {
+      return lookupResults;
+    }
+
+    /* Obtain a map from relay IP address strings to numbers. */
+    Map<String, Long> addressStringNumbers = new HashMap<String, Long>();
+    for (String addressString : addressStrings) {
+      long addressNumber = this.parseAddressString(addressString);
+      if (addressNumber >= 0L) {
+        addressStringNumbers.put(addressString, addressNumber);
+      }
+    }
+    if (addressStringNumbers.isEmpty()) {
+      return lookupResults;
+    }
+
+    /* Obtain a map from IP address numbers to blocks and to latitudes and
+       longitudes. */
+    Map<Long, Long> addressNumberBlocks = new HashMap<Long, Long>();
+    Map<Long, Float[]> addressNumberLatLong =
+        new HashMap<Long, Float[]>();
+    try {
+      SortedSet<Long> sortedAddressNumbers = new TreeSet<Long>(
+          addressStringNumbers.values());
+      BufferedReader br = new BufferedReader(new InputStreamReader(
+          new FileInputStream(geoLite2CityBlocksCsvFile), "ISO-8859-1"));
+      String line = br.readLine();
+      while ((line = br.readLine()) != null) {
+        if (!line.startsWith("::ffff:")) {
+          /* TODO Make this less hacky and IPv6-ready at some point. */
+          continue;
+        }
+        String[] parts = line.replaceAll("\"", "").split(",", 10);
+        if (parts.length != 10) {
+          System.err.println("Illegal line '" + line + "' in "
+              + geoLite2CityBlocksCsvFile.getAbsolutePath() + ".");
+          br.close();
+          return lookupResults;
+        }
+        try {
+          String startAddressString = parts[0].substring(7); /* ::ffff: */
+          long startIpNum = this.parseAddressString(startAddressString);
+          if (startIpNum < 0L) {
+            System.err.println("Illegal IP address in '" + line
+                + "' in " + geoLite2CityBlocksCsvFile.getAbsolutePath()
+                + ".");
+            br.close();
+            return lookupResults;
+          }
+          int networkMaskLength = Integer.parseInt(parts[1]);
+          if (networkMaskLength < 96 || networkMaskLength > 128) {
+            System.err.println("Illegal network mask in '" + line
+                + "' in " + geoLite2CityBlocksCsvFile.getAbsolutePath()
+                + ".");
+            br.close();
+            return lookupResults;
+          }
+          if (parts[2].length() == 0 && parts[3].length() == 0) {
+            continue;
+          }
+          long endIpNum = startIpNum + (1 << (128 - networkMaskLength))
+              - 1;
+          for (long addressNumber : sortedAddressNumbers.
+              tailSet(startIpNum).headSet(endIpNum + 1L)) {
+            String blockString = parts[2].length() > 0 ? parts[2] :
+                parts[3];
+            long blockNumber = Long.parseLong(blockString);
+            addressNumberBlocks.put(addressNumber, blockNumber);
+            if (parts[6].length() > 0 && parts[7].length() > 0) {
+              addressNumberLatLong.put(addressNumber,
+                  new Float[] { Float.parseFloat(parts[6]),
+                  Float.parseFloat(parts[7]) });
+            }
+          }
+        } catch (NumberFormatException e) {
+          System.err.println("Number format exception while parsing line "
+              + "'" + line + "' in "
+              + geoLite2CityBlocksCsvFile.getAbsolutePath() + ".");
+          br.close();
+          return lookupResults;
+        }
+      }
+      br.close();
+    } catch (IOException e) {
+      System.err.println("I/O exception while reading "
+          + geoLite2CityBlocksCsvFile.getAbsolutePath() + ".");
+      return lookupResults;
+    }
+
+    /* Obtain a map from relevant blocks to location lines. */
+    Map<Long, String> blockLocations = new HashMap<Long, String>();
+    try {
+      Set<Long> blockNumbers = new HashSet<Long>(
+          addressNumberBlocks.values());
+      BufferedReader br = new BufferedReader(new InputStreamReader(
+          new FileInputStream(geoLite2CityLocationsCsvFile),
+          "ISO-8859-1"));
+      String line = br.readLine();
+      while ((line = br.readLine()) != null) {
+        String[] parts = line.replaceAll("\"", "").split(",", 10);
+        if (parts.length != 10) {
+          System.err.println("Illegal line '" + line + "' in "
+              + geoLite2CityLocationsCsvFile.getAbsolutePath() + ".");
+          br.close();
+          return lookupResults;
+        }
+        try {
+          long locId = Long.parseLong(parts[0]);
+          if (blockNumbers.contains(locId)) {
+            blockLocations.put(locId, line);
+          }
+        } catch (NumberFormatException e) {
+          System.err.println("Number format exception while parsing line "
+              + "'" + line + "' in "
+              + geoLite2CityLocationsCsvFile.getAbsolutePath() + ".");
+          br.close();
+          return lookupResults;
+        }
+      }
+      br.close();
+    } catch (IOException e) {
+      System.err.println("I/O exception while reading "
+          + geoLite2CityLocationsCsvFile.getAbsolutePath() + ".");
+      return lookupResults;
+    }
+
+    /* Obtain a map from IP address numbers to ASN. */
+    Map<Long, String> addressNumberASN = new HashMap<Long, String>();
+    try {
+      SortedSet<Long> sortedAddressNumbers = new TreeSet<Long>(
+          addressStringNumbers.values());
+      long firstAddressNumber = sortedAddressNumbers.first();
+      BufferedReader br = new BufferedReader(new InputStreamReader(
+          new FileInputStream(geoIPASNum2CsvFile), "ISO-8859-1"));
+      String line;
+      long previousStartIpNum = -1L;
+      while ((line = br.readLine()) != null) {
+        String[] parts = line.replaceAll("\"", "").split(",", 3);
+        if (parts.length != 3) {
+          System.err.println("Illegal line '" + line + "' in "
+              + geoIPASNum2CsvFile.getAbsolutePath() + ".");
+          br.close();
+          return lookupResults;
+        }
+        try {
+          long startIpNum = Long.parseLong(parts[0]);
+          if (startIpNum <= previousStartIpNum) {
+            System.err.println("Line '" + line + "' not sorted in "
+                + geoIPASNum2CsvFile.getAbsolutePath() + ".");
+            br.close();
+            return lookupResults;
+          }
+          previousStartIpNum = startIpNum;
+          while (firstAddressNumber < startIpNum &&
+              firstAddressNumber != -1L) {
+            sortedAddressNumbers.remove(firstAddressNumber);
+            if (sortedAddressNumbers.isEmpty()) {
+              firstAddressNumber = -1L;
+            } else {
+              firstAddressNumber = sortedAddressNumbers.first();
+            }
+          }
+          long endIpNum = Long.parseLong(parts[1]);
+          while (firstAddressNumber <= endIpNum &&
+              firstAddressNumber != -1L) {
+            if (parts[2].startsWith("AS") &&
+                parts[2].split(" ", 2).length == 2) {
+              addressNumberASN.put(firstAddressNumber, parts[2]);
+            }
+            sortedAddressNumbers.remove(firstAddressNumber);
+            if (sortedAddressNumbers.isEmpty()) {
+              firstAddressNumber = -1L;
+            } else {
+              firstAddressNumber = sortedAddressNumbers.first();
+            }
+          }
+          if (firstAddressNumber == -1L) {
+            break;
+          }
+        }
+        catch (NumberFormatException e) {
+          System.err.println("Number format exception while parsing line "
+              + "'" + line + "' in "
+              + geoIPASNum2CsvFile.getAbsolutePath() + ".");
+          br.close();
+          return lookupResults;
+        }
+      }
+      br.close();
+    } catch (IOException e) {
+      System.err.println("I/O exception while reading "
+          + geoIPASNum2CsvFile.getAbsolutePath() + ".");
+      return lookupResults;
+    }
+
+    /* Finally, put together lookup results. */
+    for (String addressString : addressStrings) {
+      if (!addressStringNumbers.containsKey(addressString)) {
+        continue;
+      }
+      long addressNumber = addressStringNumbers.get(addressString);
+      if (!addressNumberBlocks.containsKey(addressNumber) &&
+          !addressNumberLatLong.containsKey(addressNumber) &&
+          !addressNumberASN.containsKey(addressNumber)) {
+        continue;
+      }
+      LookupResult lookupResult = new LookupResult();
+      if (addressNumberBlocks.containsKey(addressNumber)) {
+        long blockNumber = addressNumberBlocks.get(addressNumber);
+        if (blockLocations.containsKey(blockNumber)) {
+          String[] parts = blockLocations.get(blockNumber).
+              replaceAll("\"", "").split(",", -1);
+          lookupResult.setCountryCode(parts[3].toLowerCase());
+          if (parts[4].length() > 0) {
+            lookupResult.setCountryName(parts[4]);
+          }
+          if (parts[6].length() > 0) {
+            lookupResult.setRegionName(parts[6]);
+          }
+          if (parts[7].length() > 0) {
+            lookupResult.setCityName(parts[7]);
+          }
+        }
+      }
+      if (addressNumberLatLong.containsKey(addressNumber)) {
+        Float[] latLong = addressNumberLatLong.get(addressNumber);
+        lookupResult.setLatitude(latLong[0]);
+        lookupResult.setLongitude(latLong[1]);
+      }
+      if (addressNumberASN.containsKey(addressNumber)) {
+        String[] parts = addressNumberASN.get(addressNumber).split(" ",
+            2);
+        lookupResult.setAsNumber(parts[0]);
+        lookupResult.setAsName(parts[1]);
+      }
+      lookupResults.put(addressString, lookupResult);
+    }
+
+    /* Keep statistics. */
+    this.addressesLookedUp += addressStrings.size();
+    this.addressesResolved += lookupResults.size();
+
+    return lookupResults;
+  }
+
+  private int addressesLookedUp = 0, addressesResolved = 0;
+
+  public String getStatsString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("    " + Logger.formatDecimalNumber(addressesLookedUp)
+        + " addresses looked up\n");
+    sb.append("    " + Logger.formatDecimalNumber(addressesResolved)
+        + " addresses resolved\n");
+    return sb.toString();
+  }
+}
diff --git a/src/org/torproject/onionoo/updater/NodeDetailsStatusUpdater.java b/src/org/torproject/onionoo/updater/NodeDetailsStatusUpdater.java
new file mode 100644
index 0000000..c687704
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/NodeDetailsStatusUpdater.java
@@ -0,0 +1,626 @@
+/* Copyright 2011--2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.torproject.descriptor.BridgeNetworkStatus;
+import org.torproject.descriptor.BridgePoolAssignment;
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.ExitList;
+import org.torproject.descriptor.ExitListEntry;
+import org.torproject.descriptor.NetworkStatusEntry;
+import org.torproject.descriptor.RelayNetworkStatusConsensus;
+import org.torproject.descriptor.ServerDescriptor;
+import org.torproject.onionoo.docs.DetailsStatus;
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.docs.NodeStatus;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Logger;
+
+public class NodeDetailsStatusUpdater implements DescriptorListener,
+    StatusUpdater {
+
+  private DescriptorSource descriptorSource;
+
+  private ReverseDomainNameResolver reverseDomainNameResolver;
+
+  private LookupService lookupService;
+
+  private DocumentStore documentStore;
+
+  private long now;
+
+  private SortedMap<String, NodeStatus> knownNodes =
+      new TreeMap<String, NodeStatus>();
+
+  private SortedMap<String, NodeStatus> relays;
+
+  private SortedMap<String, NodeStatus> bridges;
+
+  private long relaysLastValidAfterMillis = -1L;
+
+  private long bridgesLastPublishedMillis = -1L;
+
+  private SortedMap<String, Integer> lastBandwidthWeights = null;
+
+  private int relayConsensusesProcessed = 0, bridgeStatusesProcessed = 0;
+
+  public NodeDetailsStatusUpdater(
+      ReverseDomainNameResolver reverseDomainNameResolver,
+      LookupService lookupService) {
+    this.descriptorSource = ApplicationFactory.getDescriptorSource();
+    this.reverseDomainNameResolver = reverseDomainNameResolver;
+    this.lookupService = lookupService;
+    this.documentStore = ApplicationFactory.getDocumentStore();
+    this.now = ApplicationFactory.getTime().currentTimeMillis();
+    this.registerDescriptorListeners();
+  }
+
+  private void registerDescriptorListeners() {
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.RELAY_CONSENSUSES);
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.RELAY_SERVER_DESCRIPTORS);
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.BRIDGE_STATUSES);
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.BRIDGE_SERVER_DESCRIPTORS);
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.BRIDGE_POOL_ASSIGNMENTS);
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.EXIT_LISTS);
+  }
+
+  public void processDescriptor(Descriptor descriptor, boolean relay) {
+    if (descriptor instanceof ServerDescriptor && relay) {
+      this.processRelayServerDescriptor((ServerDescriptor) descriptor);
+    } else if (descriptor instanceof ExitList) {
+      this.processExitList((ExitList) descriptor);
+    } else if (descriptor instanceof RelayNetworkStatusConsensus) {
+      this.processRelayNetworkStatusConsensus(
+          (RelayNetworkStatusConsensus) descriptor);
+    } else if (descriptor instanceof ServerDescriptor && !relay) {
+      this.processBridgeServerDescriptor((ServerDescriptor) descriptor);
+    } else if (descriptor instanceof BridgePoolAssignment) {
+      this.processBridgePoolAssignment((BridgePoolAssignment) descriptor);
+    } else if (descriptor instanceof BridgeNetworkStatus) {
+      this.processBridgeNetworkStatus((BridgeNetworkStatus) descriptor);
+    }
+  }
+
+  private void processRelayServerDescriptor(
+      ServerDescriptor descriptor) {
+    String fingerprint = descriptor.getFingerprint();
+    DetailsStatus detailsStatus = this.documentStore.retrieve(
+        DetailsStatus.class, true, fingerprint);
+    String publishedDateTime =
+        DateTimeHelper.format(descriptor.getPublishedMillis());
+    if (detailsStatus == null) {
+      detailsStatus = new DetailsStatus();
+    } else if (detailsStatus.getDescPublished() != null &&
+        publishedDateTime.compareTo(
+            detailsStatus.getDescPublished()) < 0) {
+      return;
+    }
+    String lastRestartedString = DateTimeHelper.format(
+        descriptor.getPublishedMillis() - descriptor.getUptime()
+        * DateTimeHelper.ONE_SECOND);
+    int bandwidthRate = descriptor.getBandwidthRate();
+    int bandwidthBurst = descriptor.getBandwidthBurst();
+    int observedBandwidth = descriptor.getBandwidthObserved();
+    int advertisedBandwidth = Math.min(bandwidthRate,
+        Math.min(bandwidthBurst, observedBandwidth));
+    detailsStatus.setDescPublished(publishedDateTime);
+    detailsStatus.setLastRestarted(lastRestartedString);
+    detailsStatus.setBandwidthRate(bandwidthRate);
+    detailsStatus.setBandwidthBurst(bandwidthBurst);
+    detailsStatus.setObservedBandwidth(observedBandwidth);
+    detailsStatus.setAdvertisedBandwidth(advertisedBandwidth);
+    detailsStatus.setExitPolicy(descriptor.getExitPolicyLines());
+    detailsStatus.setContact(descriptor.getContact());
+    detailsStatus.setPlatform(descriptor.getPlatform());
+    detailsStatus.setFamily(descriptor.getFamilyEntries());
+    if (descriptor.getIpv6DefaultPolicy() != null &&
+        (descriptor.getIpv6DefaultPolicy().equals("accept") ||
+        descriptor.getIpv6DefaultPolicy().equals("reject")) &&
+        descriptor.getIpv6PortList() != null) {
+      Map<String, List<String>> exitPolicyV6Summary =
+          new HashMap<String, List<String>>();
+      List<String> portsOrPortRanges = Arrays.asList(
+          descriptor.getIpv6PortList().split(","));
+      exitPolicyV6Summary.put(descriptor.getIpv6DefaultPolicy(),
+          portsOrPortRanges);
+      detailsStatus.setExitPolicyV6Summary(exitPolicyV6Summary);
+    }
+    if (descriptor.isHibernating()) {
+      detailsStatus.setHibernating(true);
+    }
+    this.documentStore.store(detailsStatus, fingerprint);
+  }
+
+  private Map<String, Map<String, Long>> exitListEntries =
+      new HashMap<String, Map<String, Long>>();
+
+  private void processExitList(ExitList exitList) {
+    for (ExitListEntry exitListEntry : exitList.getExitListEntries()) {
+      String fingerprint = exitListEntry.getFingerprint();
+      if (exitListEntry.getScanMillis() <
+          this.now - DateTimeHelper.ONE_DAY) {
+        continue;
+      }
+      if (!this.exitListEntries.containsKey(fingerprint)) {
+        this.exitListEntries.put(fingerprint,
+            new HashMap<String, Long>());
+      }
+      String exitAddress = exitListEntry.getExitAddress();
+      long scanMillis = exitListEntry.getScanMillis();
+      if (!this.exitListEntries.get(fingerprint).containsKey(exitAddress)
+          || this.exitListEntries.get(fingerprint).get(exitAddress)
+          < scanMillis) {
+        this.exitListEntries.get(fingerprint).put(exitAddress,
+            scanMillis);
+      }
+    }
+  }
+
+  private void processRelayNetworkStatusConsensus(
+      RelayNetworkStatusConsensus consensus) {
+    long validAfterMillis = consensus.getValidAfterMillis();
+    if (validAfterMillis > this.relaysLastValidAfterMillis) {
+      this.relaysLastValidAfterMillis = validAfterMillis;
+    }
+    Set<String> recommendedVersions = null;
+    if (consensus.getRecommendedServerVersions() != null) {
+      recommendedVersions = new HashSet<String>();
+      for (String recommendedVersion :
+          consensus.getRecommendedServerVersions()) {
+        recommendedVersions.add("Tor " + recommendedVersion);
+      }
+    }
+    for (NetworkStatusEntry entry :
+        consensus.getStatusEntries().values()) {
+      String nickname = entry.getNickname();
+      String fingerprint = entry.getFingerprint();
+      String address = entry.getAddress();
+      SortedSet<String> orAddressesAndPorts = new TreeSet<String>(
+          entry.getOrAddresses());
+      int orPort = entry.getOrPort();
+      int dirPort = entry.getDirPort();
+      SortedSet<String> relayFlags = entry.getFlags();
+      long consensusWeight = entry.getBandwidth();
+      String defaultPolicy = entry.getDefaultPolicy();
+      String portList = entry.getPortList();
+      Boolean recommendedVersion = (recommendedVersions == null ||
+          entry.getVersion() == null) ? null :
+          recommendedVersions.contains(entry.getVersion());
+      NodeStatus newNodeStatus = new NodeStatus(true, nickname,
+          fingerprint, address, orAddressesAndPorts, null,
+          validAfterMillis, orPort, dirPort, relayFlags, consensusWeight,
+          null, null, -1L, defaultPolicy, portList, validAfterMillis,
+          validAfterMillis, null, null, recommendedVersion, null);
+      if (this.knownNodes.containsKey(fingerprint)) {
+        this.knownNodes.get(fingerprint).update(newNodeStatus);
+      } else {
+        this.knownNodes.put(fingerprint, newNodeStatus);
+      }
+    }
+    this.relayConsensusesProcessed++;
+    if (this.relaysLastValidAfterMillis == validAfterMillis) {
+      this.lastBandwidthWeights = consensus.getBandwidthWeights();
+    }
+  }
+
+  private void processBridgeServerDescriptor(
+      ServerDescriptor descriptor) {
+    String fingerprint = descriptor.getFingerprint();
+    DetailsStatus detailsStatus = this.documentStore.retrieve(
+        DetailsStatus.class, true, fingerprint);
+    String publishedDateTime =
+        DateTimeHelper.format(descriptor.getPublishedMillis());
+    if (detailsStatus == null) {
+      detailsStatus = new DetailsStatus();
+    } else if (detailsStatus.getDescPublished() != null &&
+        publishedDateTime.compareTo(
+            detailsStatus.getDescPublished()) < 0) {
+      return;
+    }
+    String lastRestartedString = DateTimeHelper.format(
+        descriptor.getPublishedMillis() - descriptor.getUptime()
+        * DateTimeHelper.ONE_SECOND);
+    int advertisedBandwidth = Math.min(descriptor.getBandwidthRate(),
+        Math.min(descriptor.getBandwidthBurst(),
+        descriptor.getBandwidthObserved()));
+    detailsStatus.setDescPublished(publishedDateTime);
+    detailsStatus.setLastRestarted(lastRestartedString);
+    detailsStatus.setAdvertisedBandwidth(advertisedBandwidth);
+    detailsStatus.setPlatform(descriptor.getPlatform());
+    this.documentStore.store(detailsStatus, fingerprint);
+  }
+
+  private void processBridgePoolAssignment(
+      BridgePoolAssignment bridgePoolAssignment) {
+    for (Map.Entry<String, String> e :
+        bridgePoolAssignment.getEntries().entrySet()) {
+      String fingerprint = e.getKey();
+      String details = e.getValue();
+      DetailsStatus detailsStatus = this.documentStore.retrieve(
+          DetailsStatus.class, true, fingerprint);
+      if (detailsStatus == null) {
+        detailsStatus = new DetailsStatus();
+      } else if (details.equals(detailsStatus.getPoolAssignment())) {
+        continue;
+      }
+      detailsStatus.setPoolAssignment(details);
+      this.documentStore.store(detailsStatus, fingerprint);
+    }
+  }
+
+  private void processBridgeNetworkStatus(BridgeNetworkStatus status) {
+    long publishedMillis = status.getPublishedMillis();
+    if (publishedMillis > this.bridgesLastPublishedMillis) {
+      this.bridgesLastPublishedMillis = publishedMillis;
+    }
+    for (NetworkStatusEntry entry : status.getStatusEntries().values()) {
+      String nickname = entry.getNickname();
+      String fingerprint = entry.getFingerprint();
+      String address = entry.getAddress();
+      SortedSet<String> orAddressesAndPorts = new TreeSet<String>(
+          entry.getOrAddresses());
+      int orPort = entry.getOrPort();
+      int dirPort = entry.getDirPort();
+      SortedSet<String> relayFlags = entry.getFlags();
+      NodeStatus newNodeStatus = new NodeStatus(false, nickname,
+          fingerprint, address, orAddressesAndPorts, null,
+          publishedMillis, orPort, dirPort, relayFlags, -1L, "??", null,
+          -1L, null, null, publishedMillis, -1L, null, null, null, null);
+      if (this.knownNodes.containsKey(fingerprint)) {
+        this.knownNodes.get(fingerprint).update(newNodeStatus);
+      } else {
+        this.knownNodes.put(fingerprint, newNodeStatus);
+      }
+    }
+    this.bridgeStatusesProcessed++;
+  }
+
+  public void updateStatuses() {
+    this.readStatusSummary();
+    Logger.printStatusTime("Read status summary");
+    this.setCurrentNodes();
+    Logger.printStatusTime("Set current node fingerprints");
+    this.startReverseDomainNameLookups();
+    Logger.printStatusTime("Started reverse domain name lookups");
+    this.lookUpCitiesAndASes();
+    Logger.printStatusTime("Looked up cities and ASes");
+    this.setDescriptorPartsOfNodeStatus();
+    Logger.printStatusTime("Set descriptor parts of node statuses.");
+    this.calculatePathSelectionProbabilities();
+    Logger.printStatusTime("Calculated path selection probabilities");
+    this.finishReverseDomainNameLookups();
+    Logger.printStatusTime("Finished reverse domain name lookups");
+    this.writeStatusSummary();
+    Logger.printStatusTime("Wrote status summary");
+    this.updateDetailsStatuses();
+    Logger.printStatusTime("Updated exit addresses in details statuses");
+  }
+
+  private void readStatusSummary() {
+    SortedSet<String> fingerprints = this.documentStore.list(
+        NodeStatus.class);
+    for (String fingerprint : fingerprints) {
+      NodeStatus node = this.documentStore.retrieve(NodeStatus.class,
+          true, fingerprint);
+      if (node.isRelay()) {
+        this.relaysLastValidAfterMillis = Math.max(
+            this.relaysLastValidAfterMillis, node.getLastSeenMillis());
+      } else {
+        this.bridgesLastPublishedMillis = Math.max(
+            this.bridgesLastPublishedMillis, node.getLastSeenMillis());
+      }
+      if (this.knownNodes.containsKey(fingerprint)) {
+        this.knownNodes.get(fingerprint).update(node);
+      } else {
+        this.knownNodes.put(fingerprint, node);
+      }
+    }
+  }
+
+  private void setCurrentNodes() {
+    long cutoff = Math.max(this.relaysLastValidAfterMillis,
+        this.bridgesLastPublishedMillis) - 7L * 24L * 60L * 60L * 1000L;
+    SortedMap<String, NodeStatus> currentNodes =
+        new TreeMap<String, NodeStatus>();
+    for (Map.Entry<String, NodeStatus> e : this.knownNodes.entrySet()) {
+      if (e.getValue().getLastSeenMillis() >= cutoff) {
+        currentNodes.put(e.getKey(), e.getValue());
+      }
+    }
+    this.relays = new TreeMap<String, NodeStatus>();
+    this.bridges = new TreeMap<String, NodeStatus>();
+    for (Map.Entry<String, NodeStatus> e : currentNodes.entrySet()) {
+      if (e.getValue().isRelay()) {
+        this.relays.put(e.getKey(), e.getValue());
+      } else {
+        this.bridges.put(e.getKey(), e.getValue());
+      }
+    }
+  }
+
+  private void startReverseDomainNameLookups() {
+    Map<String, Long> addressLastLookupTimes =
+        new HashMap<String, Long>();
+    for (NodeStatus relay : relays.values()) {
+      addressLastLookupTimes.put(relay.getAddress(),
+          relay.getLastRdnsLookup());
+    }
+    this.reverseDomainNameResolver.setAddresses(addressLastLookupTimes);
+    this.reverseDomainNameResolver.startReverseDomainNameLookups();
+  }
+
+  private void lookUpCitiesAndASes() {
+    SortedSet<String> addressStrings = new TreeSet<String>();
+    for (NodeStatus node : this.knownNodes.values()) {
+      if (node.isRelay()) {
+        addressStrings.add(node.getAddress());
+      }
+    }
+    if (addressStrings.isEmpty()) {
+      System.err.println("No relay IP addresses to resolve to cities or "
+          + "ASN.");
+      return;
+    }
+    SortedMap<String, LookupResult> lookupResults =
+        this.lookupService.lookup(addressStrings);
+    for (NodeStatus node : knownNodes.values()) {
+      if (!node.isRelay()) {
+        continue;
+      }
+      String addressString = node.getAddress();
+      if (lookupResults.containsKey(addressString)) {
+        LookupResult lookupResult = lookupResults.get(addressString);
+        node.setCountryCode(lookupResult.getCountryCode());
+        node.setCountryName(lookupResult.getCountryName());
+        node.setRegionName(lookupResult.getRegionName());
+        node.setCityName(lookupResult.getCityName());
+        node.setLatitude(lookupResult.getLatitude());
+        node.setLongitude(lookupResult.getLongitude());
+        node.setASNumber(lookupResult.getAsNumber());
+        node.setASName(lookupResult.getAsName());
+      }
+    }
+  }
+
+  private void setDescriptorPartsOfNodeStatus() {
+    for (Map.Entry<String, NodeStatus> e : this.knownNodes.entrySet()) {
+      String fingerprint = e.getKey();
+      NodeStatus node = e.getValue();
+      if (node.isRelay()) {
+        if (node.getRelayFlags().contains("Running") &&
+            node.getLastSeenMillis() == this.relaysLastValidAfterMillis) {
+          node.setRunning(true);
+        }
+        DetailsStatus detailsStatus = this.documentStore.retrieve(
+            DetailsStatus.class, true, fingerprint);
+        if (detailsStatus != null) {
+          node.setContact(detailsStatus.getContact());
+          if (detailsStatus.getExitAddresses() != null) {
+            for (Map.Entry<String, Long> ea :
+                detailsStatus.getExitAddresses().entrySet()) {
+              if (ea.getValue() >= this.now - DateTimeHelper.ONE_DAY) {
+                node.addExitAddress(ea.getKey());
+              }
+            }
+          }
+          if (detailsStatus.getFamily() != null &&
+              !detailsStatus.getFamily().isEmpty()) {
+            SortedSet<String> familyFingerprints = new TreeSet<String>();
+            for (String familyMember : detailsStatus.getFamily()) {
+              if (familyMember.startsWith("$") &&
+                  familyMember.length() == 41) {
+                familyFingerprints.add(familyMember.substring(1));
+              }
+            }
+            if (!familyFingerprints.isEmpty()) {
+              node.setFamilyFingerprints(familyFingerprints);
+            }
+          }
+        }
+      }
+      if (!node.isRelay() && node.getRelayFlags().contains("Running") &&
+          node.getLastSeenMillis() == this.bridgesLastPublishedMillis) {
+        node.setRunning(true);
+      }
+    }
+  }
+
+  private void calculatePathSelectionProbabilities() {
+    boolean consensusContainsBandwidthWeights = false;
+    double wgg = 0.0, wgd = 0.0, wmg = 0.0, wmm = 0.0, wme = 0.0,
+        wmd = 0.0, wee = 0.0, wed = 0.0;
+    if (this.lastBandwidthWeights != null) {
+      SortedSet<String> weightKeys = new TreeSet<String>(Arrays.asList(
+          "Wgg,Wgd,Wmg,Wmm,Wme,Wmd,Wee,Wed".split(",")));
+      weightKeys.removeAll(this.lastBandwidthWeights.keySet());
+      if (weightKeys.isEmpty()) {
+        consensusContainsBandwidthWeights = true;
+        wgg = ((double) this.lastBandwidthWeights.get("Wgg")) / 10000.0;
+        wgd = ((double) this.lastBandwidthWeights.get("Wgd")) / 10000.0;
+        wmg = ((double) this.lastBandwidthWeights.get("Wmg")) / 10000.0;
+        wmm = ((double) this.lastBandwidthWeights.get("Wmm")) / 10000.0;
+        wme = ((double) this.lastBandwidthWeights.get("Wme")) / 10000.0;
+        wmd = ((double) this.lastBandwidthWeights.get("Wmd")) / 10000.0;
+        wee = ((double) this.lastBandwidthWeights.get("Wee")) / 10000.0;
+        wed = ((double) this.lastBandwidthWeights.get("Wed")) / 10000.0;
+      }
+    } else {
+      System.err.println("Could not determine most recent Wxx parameter "
+          + "values, probably because we didn't parse a consensus in "
+          + "this execution.  All relays' guard/middle/exit weights are "
+          + "going to be 0.0.");
+    }
+    SortedMap<String, Double>
+        advertisedBandwidths = new TreeMap<String, Double>(),
+        consensusWeights = new TreeMap<String, Double>(),
+        guardWeights = new TreeMap<String, Double>(),
+        middleWeights = new TreeMap<String, Double>(),
+        exitWeights = new TreeMap<String, Double>();
+    double totalAdvertisedBandwidth = 0.0;
+    double totalConsensusWeight = 0.0;
+    double totalGuardWeight = 0.0;
+    double totalMiddleWeight = 0.0;
+    double totalExitWeight = 0.0;
+    for (Map.Entry<String, NodeStatus> e : this.relays.entrySet()) {
+      String fingerprint = e.getKey();
+      NodeStatus relay = e.getValue();
+      if (!relay.getRunning()) {
+        continue;
+      }
+      boolean isExit = relay.getRelayFlags().contains("Exit") &&
+          !relay.getRelayFlags().contains("BadExit");
+      boolean isGuard = relay.getRelayFlags().contains("Guard");
+      DetailsStatus detailsStatus = this.documentStore.retrieve(
+          DetailsStatus.class, true, fingerprint);
+      if (detailsStatus != null) {
+        double advertisedBandwidth =
+            detailsStatus.getAdvertisedBandwidth();
+        if (advertisedBandwidth >= 0.0) {
+          advertisedBandwidths.put(fingerprint, advertisedBandwidth);
+          totalAdvertisedBandwidth += advertisedBandwidth;
+        }
+      }
+      double consensusWeight = (double) relay.getConsensusWeight();
+      consensusWeights.put(fingerprint, consensusWeight);
+      totalConsensusWeight += consensusWeight;
+      if (consensusContainsBandwidthWeights) {
+        double guardWeight = consensusWeight,
+            middleWeight = consensusWeight,
+            exitWeight = consensusWeight;
+        if (isGuard && isExit) {
+          guardWeight *= wgd;
+          middleWeight *= wmd;
+          exitWeight *= wed;
+        } else if (isGuard) {
+          guardWeight *= wgg;
+          middleWeight *= wmg;
+          exitWeight = 0.0;
+        } else if (isExit) {
+          guardWeight = 0.0;
+          middleWeight *= wme;
+          exitWeight *= wee;
+        } else {
+          guardWeight = 0.0;
+          middleWeight *= wmm;
+          exitWeight = 0.0;
+        }
+        guardWeights.put(fingerprint, guardWeight);
+        middleWeights.put(fingerprint, middleWeight);
+        exitWeights.put(fingerprint, exitWeight);
+        totalGuardWeight += guardWeight;
+        totalMiddleWeight += middleWeight;
+        totalExitWeight += exitWeight;
+      }
+    }
+    for (Map.Entry<String, NodeStatus> e : this.relays.entrySet()) {
+      String fingerprint = e.getKey();
+      NodeStatus relay = e.getValue();
+      if (advertisedBandwidths.containsKey(fingerprint)) {
+        relay.setAdvertisedBandwidthFraction(advertisedBandwidths.get(
+            fingerprint) / totalAdvertisedBandwidth);
+      }
+      if (consensusWeights.containsKey(fingerprint)) {
+        relay.setConsensusWeightFraction(consensusWeights.get(fingerprint)
+            / totalConsensusWeight);
+      }
+      if (guardWeights.containsKey(fingerprint)) {
+        relay.setGuardProbability(guardWeights.get(fingerprint)
+            / totalGuardWeight);
+      }
+      if (middleWeights.containsKey(fingerprint)) {
+        relay.setMiddleProbability(middleWeights.get(fingerprint)
+            / totalMiddleWeight);
+      }
+      if (exitWeights.containsKey(fingerprint)) {
+        relay.setExitProbability(exitWeights.get(fingerprint)
+            / totalExitWeight);
+      }
+    }
+  }
+
+  private void finishReverseDomainNameLookups() {
+    this.reverseDomainNameResolver.finishReverseDomainNameLookups();
+    Map<String, String> lookupResults =
+        this.reverseDomainNameResolver.getLookupResults();
+    long startedRdnsLookups =
+        this.reverseDomainNameResolver.getLookupStartMillis();
+    for (NodeStatus relay : relays.values()) {
+      if (lookupResults.containsKey(relay.getAddress())) {
+        relay.setHostName(lookupResults.get(relay.getAddress()));
+        relay.setLastRdnsLookup(startedRdnsLookups);
+      }
+    }
+  }
+
+  private void writeStatusSummary() {
+    for (Map.Entry<String, NodeStatus> e : this.knownNodes.entrySet()) {
+      this.documentStore.store(e.getValue(), e.getKey());
+    }
+  }
+
+  private void updateDetailsStatuses() {
+    SortedSet<String> fingerprints = new TreeSet<String>();
+    fingerprints.addAll(this.exitListEntries.keySet());
+    for (String fingerprint : fingerprints) {
+      DetailsStatus detailsStatus = this.documentStore.retrieve(
+          DetailsStatus.class, true, fingerprint);
+      if (detailsStatus == null) {
+        detailsStatus = new DetailsStatus();
+      }
+      Map<String, Long> exitAddresses = new HashMap<String, Long>();
+      if (detailsStatus.getExitAddresses() != null) {
+        for (Map.Entry<String, Long> e :
+            detailsStatus.getExitAddresses().entrySet()) {
+          if (e.getValue() >= this.now - DateTimeHelper.ONE_DAY) {
+            exitAddresses.put(e.getKey(), e.getValue());
+          }
+        }
+      }
+      if (this.exitListEntries.containsKey(fingerprint)) {
+        for (Map.Entry<String, Long> e :
+            this.exitListEntries.get(fingerprint).entrySet()) {
+          if (!exitAddresses.containsKey(e.getKey()) ||
+              exitAddresses.get(e.getKey()) < e.getValue()) {
+            exitAddresses.put(e.getKey(), e.getValue());
+          }
+        }
+      }
+      if (this.knownNodes.containsKey(fingerprint)) {
+        for (String orAddress :
+            this.knownNodes.get(fingerprint).getOrAddresses()) {
+          this.exitListEntries.remove(orAddress);
+        }
+      }
+      detailsStatus.setExitAddresses(exitAddresses);
+      this.documentStore.store(detailsStatus, fingerprint);
+    }
+  }
+
+  public String getStatsString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("    " + Logger.formatDecimalNumber(
+        relayConsensusesProcessed) + " relay consensuses processed\n");
+    sb.append("    " + Logger.formatDecimalNumber(bridgeStatusesProcessed)
+        + " bridge statuses processed\n");
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/updater/ReverseDomainNameResolver.java b/src/org/torproject/onionoo/updater/ReverseDomainNameResolver.java
new file mode 100644
index 0000000..8ca7eb4
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/ReverseDomainNameResolver.java
@@ -0,0 +1,179 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Logger;
+import org.torproject.onionoo.util.Time;
+
+public class ReverseDomainNameResolver {
+
+  private class RdnsLookupWorker extends Thread {
+    public void run() {
+      while (time.currentTimeMillis() - RDNS_LOOKUP_MAX_DURATION_MILLIS
+          <= startedRdnsLookups) {
+        String rdnsLookupJob = null;
+        synchronized (rdnsLookupJobs) {
+          for (String job : rdnsLookupJobs) {
+            rdnsLookupJob = job;
+            rdnsLookupJobs.remove(job);
+            break;
+          }
+        }
+        if (rdnsLookupJob == null) {
+          break;
+        }
+        RdnsLookupRequest request = new RdnsLookupRequest(this,
+            rdnsLookupJob);
+        request.setDaemon(true);
+        request.start();
+        try {
+          Thread.sleep(RDNS_LOOKUP_MAX_REQUEST_MILLIS);
+        } catch (InterruptedException e) {
+          /* Getting interrupted should be the default case. */
+        }
+        String hostName = request.getHostName();
+        if (hostName != null) {
+          synchronized (rdnsLookupResults) {
+            rdnsLookupResults.put(rdnsLookupJob, hostName);
+          }
+        }
+        long lookupMillis = request.getLookupMillis();
+        if (lookupMillis >= 0L) {
+          synchronized (rdnsLookupMillis) {
+            rdnsLookupMillis.add(lookupMillis);
+          }
+        }
+      }
+    }
+  }
+
+  private class RdnsLookupRequest extends Thread {
+    private RdnsLookupWorker parent;
+    private String address, hostName;
+    private long lookupStartedMillis = -1L, lookupCompletedMillis = -1L;
+    public RdnsLookupRequest(RdnsLookupWorker parent, String address) {
+      this.parent = parent;
+      this.address = address;
+    }
+    public void run() {
+      this.lookupStartedMillis = time.currentTimeMillis();
+      try {
+        String result = InetAddress.getByName(this.address).getHostName();
+        synchronized (this) {
+          this.hostName = result;
+        }
+      } catch (UnknownHostException e) {
+        /* We'll try again the next time. */
+      }
+      this.lookupCompletedMillis = time.currentTimeMillis();
+      this.parent.interrupt();
+    }
+    public synchronized String getHostName() {
+      return hostName;
+    }
+    public synchronized long getLookupMillis() {
+      return this.lookupCompletedMillis - this.lookupStartedMillis;
+    }
+  }
+
+  private Time time;
+
+  public ReverseDomainNameResolver() {
+    this.time = ApplicationFactory.getTime();
+  }
+
+  private static final long RDNS_LOOKUP_MAX_REQUEST_MILLIS =
+      DateTimeHelper.TEN_SECONDS;
+  private static final long RDNS_LOOKUP_MAX_DURATION_MILLIS =
+      DateTimeHelper.FIVE_MINUTES;
+  private static final long RDNS_LOOKUP_MAX_AGE_MILLIS =
+      DateTimeHelper.TWELVE_HOURS;
+  private static final int RDNS_LOOKUP_WORKERS_NUM = 5;
+
+  private Map<String, Long> addressLastLookupTimes;
+
+  private Set<String> rdnsLookupJobs;
+
+  private Map<String, String> rdnsLookupResults;
+
+  private List<Long> rdnsLookupMillis;
+
+  private long startedRdnsLookups;
+
+  private List<RdnsLookupWorker> rdnsLookupWorkers;
+
+  public void setAddresses(Map<String, Long> addressLastLookupTimes) {
+    this.addressLastLookupTimes = addressLastLookupTimes;
+  }
+
+  public void startReverseDomainNameLookups() {
+    this.startedRdnsLookups = this.time.currentTimeMillis();
+    this.rdnsLookupJobs = new HashSet<String>();
+    for (Map.Entry<String, Long> e :
+        this.addressLastLookupTimes.entrySet()) {
+      if (e.getValue() < this.startedRdnsLookups
+          - RDNS_LOOKUP_MAX_AGE_MILLIS) {
+        this.rdnsLookupJobs.add(e.getKey());
+      }
+    }
+    this.rdnsLookupResults = new HashMap<String, String>();
+    this.rdnsLookupMillis = new ArrayList<Long>();
+    this.rdnsLookupWorkers = new ArrayList<RdnsLookupWorker>();
+    for (int i = 0; i < RDNS_LOOKUP_WORKERS_NUM; i++) {
+      RdnsLookupWorker rdnsLookupWorker = new RdnsLookupWorker();
+      this.rdnsLookupWorkers.add(rdnsLookupWorker);
+      rdnsLookupWorker.setDaemon(true);
+      rdnsLookupWorker.start();
+    }
+  }
+
+  public void finishReverseDomainNameLookups() {
+    for (RdnsLookupWorker rdnsLookupWorker : this.rdnsLookupWorkers) {
+      try {
+        rdnsLookupWorker.join();
+      } catch (InterruptedException e) {
+        /* This is not something that we can take care of.  Just leave the
+         * worker thread alone. */
+      }
+    }
+  }
+
+  public Map<String, String> getLookupResults() {
+    synchronized (this.rdnsLookupResults) {
+      return new HashMap<String, String>(this.rdnsLookupResults);
+    }
+  }
+
+  public long getLookupStartMillis() {
+    return this.startedRdnsLookups;
+  }
+
+  public String getStatsString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("    " + Logger.formatDecimalNumber(rdnsLookupMillis.size())
+        + " lookups performed\n");
+    if (rdnsLookupMillis.size() > 0) {
+      Collections.sort(rdnsLookupMillis);
+      sb.append("    " + Logger.formatMillis(rdnsLookupMillis.get(0))
+          + " minimum lookup time\n");
+      sb.append("    " + Logger.formatMillis(rdnsLookupMillis.get(
+          rdnsLookupMillis.size() / 2)) + " median lookup time\n");
+      sb.append("    " + Logger.formatMillis(rdnsLookupMillis.get(
+          rdnsLookupMillis.size() - 1)) + " maximum lookup time\n");
+    }
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/updater/StatusUpdater.java b/src/org/torproject/onionoo/updater/StatusUpdater.java
new file mode 100644
index 0000000..9fc34d3
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/StatusUpdater.java
@@ -0,0 +1,11 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+public interface StatusUpdater {
+
+  public abstract void updateStatuses();
+
+  public abstract String getStatsString();
+}
+
diff --git a/src/org/torproject/onionoo/updater/UptimeStatusUpdater.java b/src/org/torproject/onionoo/updater/UptimeStatusUpdater.java
new file mode 100644
index 0000000..dd71e74
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/UptimeStatusUpdater.java
@@ -0,0 +1,130 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.torproject.descriptor.BridgeNetworkStatus;
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.NetworkStatusEntry;
+import org.torproject.descriptor.RelayNetworkStatusConsensus;
+import org.torproject.onionoo.docs.UptimeStatus;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Logger;
+
+public class UptimeStatusUpdater implements DescriptorListener,
+    StatusUpdater {
+
+  private DescriptorSource descriptorSource;
+
+  public UptimeStatusUpdater() {
+    this.descriptorSource = ApplicationFactory.getDescriptorSource();
+    this.registerDescriptorListeners();
+  }
+
+  private void registerDescriptorListeners() {
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.RELAY_CONSENSUSES);
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.BRIDGE_STATUSES);
+  }
+
+  public void processDescriptor(Descriptor descriptor, boolean relay) {
+    if (descriptor instanceof RelayNetworkStatusConsensus) {
+      this.processRelayNetworkStatusConsensus(
+          (RelayNetworkStatusConsensus) descriptor);
+    } else if (descriptor instanceof BridgeNetworkStatus) {
+      this.processBridgeNetworkStatus(
+          (BridgeNetworkStatus) descriptor);
+    }
+  }
+
+  private SortedSet<Long> newRelayStatuses = new TreeSet<Long>(),
+      newBridgeStatuses = new TreeSet<Long>();
+  private SortedMap<String, SortedSet<Long>>
+      newRunningRelays = new TreeMap<String, SortedSet<Long>>(),
+      newRunningBridges = new TreeMap<String, SortedSet<Long>>();
+
+  private void processRelayNetworkStatusConsensus(
+      RelayNetworkStatusConsensus consensus) {
+    SortedSet<String> fingerprints = new TreeSet<String>();
+    for (NetworkStatusEntry entry :
+        consensus.getStatusEntries().values()) {
+      if (entry.getFlags().contains("Running")) {
+        fingerprints.add(entry.getFingerprint());
+      }
+    }
+    if (!fingerprints.isEmpty()) {
+      long dateHourMillis = (consensus.getValidAfterMillis()
+          / DateTimeHelper.ONE_HOUR) * DateTimeHelper.ONE_HOUR;
+      for (String fingerprint : fingerprints) {
+        if (!this.newRunningRelays.containsKey(fingerprint)) {
+          this.newRunningRelays.put(fingerprint, new TreeSet<Long>());
+        }
+        this.newRunningRelays.get(fingerprint).add(dateHourMillis);
+      }
+      this.newRelayStatuses.add(dateHourMillis);
+    }
+  }
+
+  private void processBridgeNetworkStatus(BridgeNetworkStatus status) {
+    SortedSet<String> fingerprints = new TreeSet<String>();
+    for (NetworkStatusEntry entry :
+        status.getStatusEntries().values()) {
+      if (entry.getFlags().contains("Running")) {
+        fingerprints.add(entry.getFingerprint());
+      }
+    }
+    if (!fingerprints.isEmpty()) {
+      long dateHourMillis = (status.getPublishedMillis()
+          / DateTimeHelper.ONE_HOUR) * DateTimeHelper.ONE_HOUR;
+      for (String fingerprint : fingerprints) {
+        if (!this.newRunningBridges.containsKey(fingerprint)) {
+          this.newRunningBridges.put(fingerprint, new TreeSet<Long>());
+        }
+        this.newRunningBridges.get(fingerprint).add(dateHourMillis);
+      }
+      this.newBridgeStatuses.add(dateHourMillis);
+    }
+  }
+
+  public void updateStatuses() {
+    for (Map.Entry<String, SortedSet<Long>> e :
+        this.newRunningRelays.entrySet()) {
+      this.updateStatus(true, e.getKey(), e.getValue());
+    }
+    this.updateStatus(true, null, this.newRelayStatuses);
+    for (Map.Entry<String, SortedSet<Long>> e :
+        this.newRunningBridges.entrySet()) {
+      this.updateStatus(false, e.getKey(), e.getValue());
+    }
+    this.updateStatus(false, null, this.newBridgeStatuses);
+  }
+
+  private void updateStatus(boolean relay, String fingerprint,
+      SortedSet<Long> newUptimeHours) {
+    UptimeStatus uptimeStatus = UptimeStatus.loadOrCreate(fingerprint);
+    uptimeStatus.addToHistory(relay, newUptimeHours);
+    uptimeStatus.storeIfChanged();
+  }
+
+  public String getStatsString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("    " + Logger.formatDecimalNumber(
+            this.newRelayStatuses.size()) + " hours of relay uptimes "
+            + "processed\n");
+    sb.append("    " + Logger.formatDecimalNumber(
+        this.newBridgeStatuses.size()) + " hours of bridge uptimes "
+        + "processed\n");
+    sb.append("    " + Logger.formatDecimalNumber(
+        this.newRunningRelays.size() + this.newRunningBridges.size())
+        + " uptime status files updated\n");
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/updater/WeightsStatusUpdater.java b/src/org/torproject/onionoo/updater/WeightsStatusUpdater.java
new file mode 100644
index 0000000..333afcc
--- /dev/null
+++ b/src/org/torproject/onionoo/updater/WeightsStatusUpdater.java
@@ -0,0 +1,332 @@
+/* Copyright 2012--2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.updater;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.NetworkStatusEntry;
+import org.torproject.descriptor.RelayNetworkStatusConsensus;
+import org.torproject.descriptor.ServerDescriptor;
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.docs.WeightsStatus;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+
+public class WeightsStatusUpdater implements DescriptorListener,
+    StatusUpdater {
+
+  private DescriptorSource descriptorSource;
+
+  private DocumentStore documentStore;
+
+  private long now;
+
+  public WeightsStatusUpdater() {
+    this.descriptorSource = ApplicationFactory.getDescriptorSource();
+    this.documentStore = ApplicationFactory.getDocumentStore();
+    this.now = ApplicationFactory.getTime().currentTimeMillis();
+    this.registerDescriptorListeners();
+  }
+
+  private void registerDescriptorListeners() {
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.RELAY_CONSENSUSES);
+    this.descriptorSource.registerDescriptorListener(this,
+        DescriptorType.RELAY_SERVER_DESCRIPTORS);
+  }
+
+  public void processDescriptor(Descriptor descriptor, boolean relay) {
+    if (descriptor instanceof ServerDescriptor) {
+      this.processRelayServerDescriptor((ServerDescriptor) descriptor);
+    } else if (descriptor instanceof RelayNetworkStatusConsensus) {
+      this.processRelayNetworkConsensus(
+          (RelayNetworkStatusConsensus) descriptor);
+    }
+  }
+
+  public void updateStatuses() {
+    /* Nothing to do. */
+  }
+
+  private void processRelayNetworkConsensus(
+      RelayNetworkStatusConsensus consensus) {
+    long validAfterMillis = consensus.getValidAfterMillis(),
+        freshUntilMillis = consensus.getFreshUntilMillis();
+    SortedMap<String, double[]> pathSelectionWeights =
+        this.calculatePathSelectionProbabilities(consensus);
+    this.updateWeightsHistory(validAfterMillis, freshUntilMillis,
+        pathSelectionWeights);
+  }
+
+  private void processRelayServerDescriptor(
+      ServerDescriptor serverDescriptor) {
+    String digest = serverDescriptor.getServerDescriptorDigest().
+        toUpperCase();
+    int advertisedBandwidth = Math.min(Math.min(
+        serverDescriptor.getBandwidthBurst(),
+        serverDescriptor.getBandwidthObserved()),
+        serverDescriptor.getBandwidthRate());
+    String fingerprint = serverDescriptor.getFingerprint();
+    WeightsStatus weightsStatus = this.documentStore.retrieve(
+        WeightsStatus.class, true, fingerprint);
+    if (weightsStatus == null) {
+      weightsStatus = new WeightsStatus();
+    }
+    weightsStatus.getAdvertisedBandwidths().put(digest,
+        advertisedBandwidth);
+    this.documentStore.store(weightsStatus, fingerprint);
+}
+
+  private void updateWeightsHistory(long validAfterMillis,
+      long freshUntilMillis,
+      SortedMap<String, double[]> pathSelectionWeights) {
+    String fingerprint = null;
+    double[] weights = null;
+    do {
+      fingerprint = null;
+      synchronized (pathSelectionWeights) {
+        if (!pathSelectionWeights.isEmpty()) {
+          fingerprint = pathSelectionWeights.firstKey();
+          weights = pathSelectionWeights.remove(fingerprint);
+        }
+      }
+      if (fingerprint != null) {
+        this.addToHistory(fingerprint, validAfterMillis,
+            freshUntilMillis, weights);
+      }
+    } while (fingerprint != null);
+  }
+
+  private SortedMap<String, double[]> calculatePathSelectionProbabilities(
+      RelayNetworkStatusConsensus consensus) {
+    boolean containsBandwidthWeights = false;
+    double wgg = 1.0, wgd = 1.0, wmg = 1.0, wmm = 1.0, wme = 1.0,
+        wmd = 1.0, wee = 1.0, wed = 1.0;
+    SortedMap<String, Integer> bandwidthWeights =
+        consensus.getBandwidthWeights();
+    if (bandwidthWeights != null) {
+      SortedSet<String> missingWeightKeys = new TreeSet<String>(
+          Arrays.asList("Wgg,Wgd,Wmg,Wmm,Wme,Wmd,Wee,Wed".split(",")));
+      missingWeightKeys.removeAll(bandwidthWeights.keySet());
+      if (missingWeightKeys.isEmpty()) {
+        wgg = ((double) bandwidthWeights.get("Wgg")) / 10000.0;
+        wgd = ((double) bandwidthWeights.get("Wgd")) / 10000.0;
+        wmg = ((double) bandwidthWeights.get("Wmg")) / 10000.0;
+        wmm = ((double) bandwidthWeights.get("Wmm")) / 10000.0;
+        wme = ((double) bandwidthWeights.get("Wme")) / 10000.0;
+        wmd = ((double) bandwidthWeights.get("Wmd")) / 10000.0;
+        wee = ((double) bandwidthWeights.get("Wee")) / 10000.0;
+        wed = ((double) bandwidthWeights.get("Wed")) / 10000.0;
+        containsBandwidthWeights = true;
+      }
+    }
+    SortedMap<String, Double>
+        advertisedBandwidths = new TreeMap<String, Double>(),
+        consensusWeights = new TreeMap<String, Double>(),
+        guardWeights = new TreeMap<String, Double>(),
+        middleWeights = new TreeMap<String, Double>(),
+        exitWeights = new TreeMap<String, Double>();
+    double totalAdvertisedBandwidth = 0.0;
+    double totalConsensusWeight = 0.0;
+    double totalGuardWeight = 0.0;
+    double totalMiddleWeight = 0.0;
+    double totalExitWeight = 0.0;
+    for (NetworkStatusEntry relay :
+        consensus.getStatusEntries().values()) {
+      String fingerprint = relay.getFingerprint();
+      if (!relay.getFlags().contains("Running")) {
+        continue;
+      }
+      String digest = relay.getDescriptor().toUpperCase();
+      WeightsStatus weightsStatus = this.documentStore.retrieve(
+          WeightsStatus.class, true, fingerprint);
+      if (weightsStatus != null &&
+          weightsStatus.getAdvertisedBandwidths() != null &&
+          weightsStatus.getAdvertisedBandwidths().containsKey(digest)) {
+        /* Read advertised bandwidth from weights status file.  Server
+         * descriptors are parsed before consensuses, so we're sure that
+         * if there's a server descriptor for this relay, it'll be
+         * contained in the weights status file by now. */
+        double advertisedBandwidth =
+            (double) weightsStatus.getAdvertisedBandwidths().get(digest);
+        advertisedBandwidths.put(fingerprint, advertisedBandwidth);
+        totalAdvertisedBandwidth += advertisedBandwidth;
+      }
+      if (relay.getBandwidth() >= 0L) {
+        double consensusWeight = (double) relay.getBandwidth();
+        consensusWeights.put(fingerprint, consensusWeight);
+        totalConsensusWeight += consensusWeight;
+        if (containsBandwidthWeights) {
+          double guardWeight = (double) relay.getBandwidth();
+          double middleWeight = (double) relay.getBandwidth();
+          double exitWeight = (double) relay.getBandwidth();
+          boolean isExit = relay.getFlags().contains("Exit") &&
+              !relay.getFlags().contains("BadExit");
+          boolean isGuard = relay.getFlags().contains("Guard");
+          if (isGuard && isExit) {
+            guardWeight *= wgd;
+            middleWeight *= wmd;
+            exitWeight *= wed;
+          } else if (isGuard) {
+            guardWeight *= wgg;
+            middleWeight *= wmg;
+            exitWeight = 0.0;
+          } else if (isExit) {
+            guardWeight = 0.0;
+            middleWeight *= wme;
+            exitWeight *= wee;
+          } else {
+            guardWeight = 0.0;
+            middleWeight *= wmm;
+            exitWeight = 0.0;
+          }
+          guardWeights.put(fingerprint, guardWeight);
+          middleWeights.put(fingerprint, middleWeight);
+          exitWeights.put(fingerprint, exitWeight);
+          totalGuardWeight += guardWeight;
+          totalMiddleWeight += middleWeight;
+          totalExitWeight += exitWeight;
+        }
+      }
+    }
+    SortedMap<String, double[]> pathSelectionProbabilities =
+        new TreeMap<String, double[]>();
+    SortedSet<String> fingerprints = new TreeSet<String>();
+    fingerprints.addAll(consensusWeights.keySet());
+    fingerprints.addAll(advertisedBandwidths.keySet());
+    for (String fingerprint : fingerprints) {
+      double[] probabilities = new double[] { -1.0, -1.0, -1.0, -1.0,
+          -1.0, -1.0, -1.0 };
+      if (consensusWeights.containsKey(fingerprint) &&
+          totalConsensusWeight > 0.0) {
+        probabilities[1] = consensusWeights.get(fingerprint) /
+            totalConsensusWeight;
+        probabilities[6] = consensusWeights.get(fingerprint);
+      }
+      if (guardWeights.containsKey(fingerprint) &&
+          totalGuardWeight > 0.0) {
+        probabilities[2] = guardWeights.get(fingerprint) /
+            totalGuardWeight;
+      }
+      if (middleWeights.containsKey(fingerprint) &&
+          totalMiddleWeight > 0.0) {
+        probabilities[3] = middleWeights.get(fingerprint) /
+            totalMiddleWeight;
+      }
+      if (exitWeights.containsKey(fingerprint) &&
+          totalExitWeight > 0.0) {
+        probabilities[4] = exitWeights.get(fingerprint) /
+            totalExitWeight;
+      }
+      if (advertisedBandwidths.containsKey(fingerprint) &&
+          totalAdvertisedBandwidth > 0.0) {
+        probabilities[0] = advertisedBandwidths.get(fingerprint)
+            / totalAdvertisedBandwidth;
+        probabilities[5] = advertisedBandwidths.get(fingerprint);
+      }
+      pathSelectionProbabilities.put(fingerprint, probabilities);
+    }
+    return pathSelectionProbabilities;
+  }
+
+  private void addToHistory(String fingerprint, long validAfterMillis,
+      long freshUntilMillis, double[] weights) {
+    WeightsStatus weightsStatus = this.documentStore.retrieve(
+        WeightsStatus.class, true, fingerprint);
+    if (weightsStatus == null) {
+      weightsStatus = new WeightsStatus();
+    }
+    SortedMap<long[], double[]> history = weightsStatus.getHistory();
+    long[] interval = new long[] { validAfterMillis, freshUntilMillis };
+    if ((history.headMap(interval).isEmpty() ||
+        history.headMap(interval).lastKey()[1] <= validAfterMillis) &&
+        (history.tailMap(interval).isEmpty() ||
+        history.tailMap(interval).firstKey()[0] >= freshUntilMillis)) {
+      history.put(interval, weights);
+      this.compressHistory(weightsStatus);
+      this.documentStore.store(weightsStatus, fingerprint);
+    }
+  }
+
+  private void compressHistory(WeightsStatus weightsStatus) {
+    SortedMap<long[], double[]> history = weightsStatus.getHistory();
+    SortedMap<long[], double[]> compressedHistory =
+        new TreeMap<long[], double[]>(history.comparator());
+    long lastStartMillis = 0L, lastEndMillis = 0L;
+    double[] lastWeights = null;
+    String lastMonthString = "1970-01";
+    int lastMissingValues = -1;
+    for (Map.Entry<long[], double[]> e : history.entrySet()) {
+      long startMillis = e.getKey()[0], endMillis = e.getKey()[1];
+      double[] weights = e.getValue();
+      long intervalLengthMillis;
+      if (this.now - endMillis <= DateTimeHelper.ONE_WEEK) {
+        intervalLengthMillis = DateTimeHelper.ONE_HOUR;
+      } else if (this.now - endMillis <=
+          DateTimeHelper.ROUGHLY_ONE_MONTH) {
+        intervalLengthMillis = DateTimeHelper.FOUR_HOURS;
+      } else if (this.now - endMillis <=
+          DateTimeHelper.ROUGHLY_THREE_MONTHS) {
+        intervalLengthMillis = DateTimeHelper.TWELVE_HOURS;
+      } else if (this.now - endMillis <=
+          DateTimeHelper.ROUGHLY_ONE_YEAR) {
+        intervalLengthMillis = DateTimeHelper.TWO_DAYS;
+      } else {
+        intervalLengthMillis = DateTimeHelper.TEN_DAYS;
+      }
+      String monthString = DateTimeHelper.format(startMillis,
+          DateTimeHelper.ISO_YEARMONTH_FORMAT);
+      int missingValues = 0;
+      for (int i = 0; i < weights.length; i++) {
+        if (weights[i] < -0.5) {
+          missingValues += 1 << i;
+        }
+      }
+      if (lastEndMillis == startMillis &&
+          ((lastEndMillis - 1L) / intervalLengthMillis) ==
+          ((endMillis - 1L) / intervalLengthMillis) &&
+          lastMonthString.equals(monthString) &&
+          lastMissingValues == missingValues) {
+        double lastIntervalInHours = (double) ((lastEndMillis
+            - lastStartMillis) / DateTimeHelper.ONE_HOUR);
+        double currentIntervalInHours = (double) ((endMillis
+            - startMillis) / DateTimeHelper.ONE_HOUR);
+        double newIntervalInHours = (double) ((endMillis
+            - lastStartMillis) / DateTimeHelper.ONE_HOUR);
+        for (int i = 0; i < lastWeights.length; i++) {
+          lastWeights[i] *= lastIntervalInHours;
+          lastWeights[i] += weights[i] * currentIntervalInHours;
+          lastWeights[i] /= newIntervalInHours;
+        }
+        lastEndMillis = endMillis;
+      } else {
+        if (lastStartMillis > 0L) {
+          compressedHistory.put(new long[] { lastStartMillis,
+              lastEndMillis }, lastWeights);
+        }
+        lastStartMillis = startMillis;
+        lastEndMillis = endMillis;
+        lastWeights = weights;
+      }
+      lastMonthString = monthString;
+      lastMissingValues = missingValues;
+    }
+    if (lastStartMillis > 0L) {
+      compressedHistory.put(new long[] { lastStartMillis, lastEndMillis },
+          lastWeights);
+    }
+    weightsStatus.setHistory(compressedHistory);
+  }
+
+  public String getStatsString() {
+    /* TODO Add statistics string. */
+    return null;
+  }
+}
+
diff --git a/src/org/torproject/onionoo/util/ApplicationFactory.java b/src/org/torproject/onionoo/util/ApplicationFactory.java
new file mode 100644
index 0000000..8eafca9
--- /dev/null
+++ b/src/org/torproject/onionoo/util/ApplicationFactory.java
@@ -0,0 +1,55 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.util;
+
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.server.NodeIndexer;
+import org.torproject.onionoo.updater.DescriptorSource;
+
+public class ApplicationFactory {
+
+  private static Time timeInstance;
+  public static void setTime(Time time) {
+    timeInstance = time;
+  }
+  public static Time getTime() {
+    if (timeInstance == null) {
+      timeInstance = new Time();
+    }
+    return timeInstance;
+  }
+
+  private static DescriptorSource descriptorSourceInstance;
+  public static void setDescriptorSource(
+      DescriptorSource descriptorSource) {
+    descriptorSourceInstance = descriptorSource;
+  }
+  public static DescriptorSource getDescriptorSource() {
+    if (descriptorSourceInstance == null) {
+      descriptorSourceInstance = new DescriptorSource();
+    }
+    return descriptorSourceInstance;
+  }
+
+  private static DocumentStore documentStoreInstance;
+  public static void setDocumentStore(DocumentStore documentStore) {
+    documentStoreInstance = documentStore;
+  }
+  public static DocumentStore getDocumentStore() {
+    if (documentStoreInstance == null) {
+      documentStoreInstance = new DocumentStore();
+    }
+    return documentStoreInstance;
+  }
+
+  private static NodeIndexer nodeIndexerInstance;
+  public static void setNodeIndexer(NodeIndexer nodeIndexer) {
+    nodeIndexerInstance = nodeIndexer;
+  }
+  public static NodeIndexer getNodeIndexer() {
+    if (nodeIndexerInstance == null) {
+      nodeIndexerInstance = new NodeIndexer();
+    }
+    return nodeIndexerInstance;
+  }
+}
diff --git a/src/org/torproject/onionoo/util/DateTimeHelper.java b/src/org/torproject/onionoo/util/DateTimeHelper.java
new file mode 100644
index 0000000..1fcf6e1
--- /dev/null
+++ b/src/org/torproject/onionoo/util/DateTimeHelper.java
@@ -0,0 +1,92 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.util;
+
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TimeZone;
+
+public class DateTimeHelper {
+
+  private DateTimeHelper() {
+  }
+
+  public static final long ONE_SECOND = 1000L,
+      TEN_SECONDS = 10L * ONE_SECOND,
+      ONE_MINUTE = 60L * ONE_SECOND,
+      FIVE_MINUTES = 5L * ONE_MINUTE,
+      FIFTEEN_MINUTES = 15L * ONE_MINUTE,
+      ONE_HOUR = 60L * ONE_MINUTE,
+      FOUR_HOURS = 4L * ONE_HOUR,
+      SIX_HOURS = 6L * ONE_HOUR,
+      TWELVE_HOURS = 12L * ONE_HOUR,
+      ONE_DAY = 24L * ONE_HOUR,
+      TWO_DAYS = 2L * ONE_DAY,
+      THREE_DAYS = 3L * ONE_DAY,
+      ONE_WEEK = 7L * ONE_DAY,
+      TEN_DAYS = 10L * ONE_DAY,
+      ROUGHLY_ONE_MONTH = 31L * ONE_DAY,
+      ROUGHLY_THREE_MONTHS = 92L * ONE_DAY,
+      ROUGHLY_ONE_YEAR = 366L * ONE_DAY,
+      ROUGHLY_FIVE_YEARS = 5L * ROUGHLY_ONE_YEAR;
+
+  public static final String ISO_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
+
+  public static final String ISO_DATETIME_TAB_FORMAT =
+      "yyyy-MM-dd\tHH:mm:ss";
+
+  public static final String ISO_YEARMONTH_FORMAT = "yyyy-MM";
+
+  public static final String DATEHOUR_NOSPACE_FORMAT = "yyyy-MM-dd-HH";
+
+  private static ThreadLocal<Map<String, DateFormat>> dateFormats =
+      new ThreadLocal<Map<String, DateFormat>> () {
+    public Map<String, DateFormat> get() {
+      return super.get();
+    }
+    protected Map<String, DateFormat> initialValue() {
+      return new HashMap<String, DateFormat>();
+    }
+    public void remove() {
+      super.remove();
+    }
+    public void set(Map<String, DateFormat> value) {
+      super.set(value);
+    }
+  };
+
+  private static DateFormat getDateFormat(String format) {
+    Map<String, DateFormat> threadDateFormats = dateFormats.get();
+    if (!threadDateFormats.containsKey(format)) {
+      DateFormat dateFormat = new SimpleDateFormat(format);
+      dateFormat.setLenient(false);
+      dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+      threadDateFormats.put(format, dateFormat);
+    }
+    return threadDateFormats.get(format);
+  }
+
+  public static String format(long millis, String format) {
+    return getDateFormat(format).format(millis);
+  }
+
+  public static String format(long millis) {
+    return format(millis, ISO_DATETIME_FORMAT);
+  }
+
+  public static long parse(String string, String format) {
+    try {
+      return getDateFormat(format).parse(string).getTime();
+    } catch (ParseException e) {
+      return -1L;
+    }
+  }
+
+  public static long parse(String string) {
+    return parse(string, ISO_DATETIME_FORMAT);
+  }
+}
+
diff --git a/src/org/torproject/onionoo/util/LockFile.java b/src/org/torproject/onionoo/util/LockFile.java
new file mode 100644
index 0000000..01c4dcb
--- /dev/null
+++ b/src/org/torproject/onionoo/util/LockFile.java
@@ -0,0 +1,43 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.util;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+
+public class LockFile {
+
+  private final File lockFile = new File("lock");
+
+  public boolean acquireLock() {
+    Time time = ApplicationFactory.getTime();
+    try {
+      if (this.lockFile.exists()) {
+        return false;
+      }
+      if (this.lockFile.getParentFile() != null) {
+        this.lockFile.getParentFile().mkdirs();
+      }
+      BufferedWriter bw = new BufferedWriter(new FileWriter(
+          this.lockFile));
+      bw.append("" + time.currentTimeMillis() + "\n");
+      bw.close();
+      return true;
+    } catch (IOException e) {
+      System.err.println("Caught exception while trying to acquire "
+          + "lock!");
+      e.printStackTrace();
+      return false;
+    }
+  }
+
+  public boolean releaseLock() {
+    if (this.lockFile.exists()) {
+      this.lockFile.delete();
+    }
+    return !this.lockFile.exists();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/util/Logger.java b/src/org/torproject/onionoo/util/Logger.java
new file mode 100644
index 0000000..443c1ca
--- /dev/null
+++ b/src/org/torproject/onionoo/util/Logger.java
@@ -0,0 +1,81 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.util;
+
+import java.util.Date;
+
+public class Logger {
+
+  private Logger() {
+  }
+
+  private static Time time;
+
+  public static void setTime() {
+    time = ApplicationFactory.getTime();
+  }
+
+  private static long currentTimeMillis() {
+    if (time == null) {
+      return System.currentTimeMillis();
+    } else {
+      return time.currentTimeMillis();
+    }
+  }
+
+  public static String formatDecimalNumber(long decimalNumber) {
+    return String.format("%,d", decimalNumber);
+  }
+
+  public static String formatMillis(long millis) {
+    return String.format("%02d:%02d.%03d minutes",
+        millis / DateTimeHelper.ONE_MINUTE,
+        (millis % DateTimeHelper.ONE_MINUTE) / DateTimeHelper.ONE_SECOND,
+        millis % DateTimeHelper.ONE_SECOND);
+  }
+
+  public static String formatBytes(long bytes) {
+    if (bytes < 1024) {
+      return bytes + " B";
+    } else {
+      int exp = (int) (Math.log(bytes) / Math.log(1024));
+      return String.format("%.1f %siB", bytes / Math.pow(1024, exp),
+          "KMGTPE".charAt(exp-1));
+    }
+  }
+
+  private static long printedLastStatusMessage = -1L;
+
+  public static void printStatus(String message) {
+    System.out.println(new Date() + ": " + message);
+    printedLastStatusMessage = currentTimeMillis();
+  }
+
+  public static void printStatistics(String component, String message) {
+    System.out.print("  " + component + " statistics:\n" + message);
+  }
+
+  public static void printStatusTime(String message) {
+    printStatusOrErrorTime(message, false);
+  }
+
+  public static void printErrorTime(String message) {
+    printStatusOrErrorTime(message, true);
+  }
+
+  private static void printStatusOrErrorTime(String message,
+      boolean printToSystemErr) {
+    long now = currentTimeMillis();
+    long millis = printedLastStatusMessage < 0 ? 0 :
+        now - printedLastStatusMessage;
+    String line = "  " + message + " (" + Logger.formatMillis(millis)
+        + ").";
+    if (printToSystemErr) {
+      System.err.println(line);
+    } else {
+      System.out.println(line);
+    }
+    printedLastStatusMessage = now;
+  }
+}
+
diff --git a/src/org/torproject/onionoo/util/Time.java b/src/org/torproject/onionoo/util/Time.java
new file mode 100644
index 0000000..126a910
--- /dev/null
+++ b/src/org/torproject/onionoo/util/Time.java
@@ -0,0 +1,14 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.util;
+
+/*
+ * Wrapper for System.currentTimeMillis() that can be replaced with a
+ * custom time source for testing.
+ */
+public class Time {
+  public long currentTimeMillis() {
+    return System.currentTimeMillis();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/writer/BandwidthDocumentWriter.java b/src/org/torproject/onionoo/writer/BandwidthDocumentWriter.java
new file mode 100644
index 0000000..908ec7c
--- /dev/null
+++ b/src/org/torproject/onionoo/writer/BandwidthDocumentWriter.java
@@ -0,0 +1,201 @@
+/* Copyright 2011--2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.writer;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+
+import org.torproject.onionoo.docs.BandwidthDocument;
+import org.torproject.onionoo.docs.BandwidthStatus;
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.docs.GraphHistory;
+import org.torproject.onionoo.updater.DescriptorSource;
+import org.torproject.onionoo.updater.DescriptorType;
+import org.torproject.onionoo.updater.FingerprintListener;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Logger;
+
+public class BandwidthDocumentWriter implements FingerprintListener,
+    DocumentWriter{
+
+  private DescriptorSource descriptorSource;
+
+  private DocumentStore documentStore;
+
+  private long now;
+
+  public BandwidthDocumentWriter() {
+    this.descriptorSource = ApplicationFactory.getDescriptorSource();
+    this.documentStore = ApplicationFactory.getDocumentStore();
+    this.now = ApplicationFactory.getTime().currentTimeMillis();
+    this.registerFingerprintListeners();
+  }
+
+  private void registerFingerprintListeners() {
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.RELAY_EXTRA_INFOS);
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.BRIDGE_EXTRA_INFOS);
+  }
+
+  private Set<String> updateBandwidthDocuments = new HashSet<String>();
+
+  public void processFingerprints(SortedSet<String> fingerprints,
+      boolean relay) {
+    this.updateBandwidthDocuments.addAll(fingerprints);
+  }
+
+  public void writeDocuments() {
+    for (String fingerprint : this.updateBandwidthDocuments) {
+      BandwidthStatus bandwidthStatus = this.documentStore.retrieve(
+          BandwidthStatus.class, true, fingerprint);
+      if (bandwidthStatus == null) {
+        continue;
+      }
+      BandwidthDocument bandwidthDocument = this.compileBandwidthDocument(
+          fingerprint, bandwidthStatus);
+      this.documentStore.store(bandwidthDocument, fingerprint);
+    }
+    Logger.printStatusTime("Wrote bandwidth document files");
+  }
+
+
+  private BandwidthDocument compileBandwidthDocument(String fingerprint,
+      BandwidthStatus bandwidthStatus) {
+    BandwidthDocument bandwidthDocument = new BandwidthDocument();
+    bandwidthDocument.setFingerprint(fingerprint);
+    bandwidthDocument.setWriteHistory(this.compileGraphType(
+        bandwidthStatus.getWriteHistory()));
+    bandwidthDocument.setReadHistory(this.compileGraphType(
+        bandwidthStatus.getReadHistory()));
+    return bandwidthDocument;
+  }
+
+  private String[] graphNames = new String[] {
+      "3_days",
+      "1_week",
+      "1_month",
+      "3_months",
+      "1_year",
+      "5_years" };
+
+  private long[] graphIntervals = new long[] {
+      DateTimeHelper.THREE_DAYS,
+      DateTimeHelper.ONE_WEEK,
+      DateTimeHelper.ROUGHLY_ONE_MONTH,
+      DateTimeHelper.ROUGHLY_THREE_MONTHS,
+      DateTimeHelper.ROUGHLY_ONE_YEAR,
+      DateTimeHelper.ROUGHLY_FIVE_YEARS };
+
+  private long[] dataPointIntervals = new long[] {
+      DateTimeHelper.FIFTEEN_MINUTES,
+      DateTimeHelper.ONE_HOUR,
+      DateTimeHelper.FOUR_HOURS,
+      DateTimeHelper.TWELVE_HOURS,
+      DateTimeHelper.TWO_DAYS,
+      DateTimeHelper.TEN_DAYS };
+
+  private Map<String, GraphHistory> compileGraphType(
+      SortedMap<Long, long[]> history) {
+    Map<String, GraphHistory> graphs =
+        new LinkedHashMap<String, GraphHistory>();
+    for (int i = 0; i < this.graphIntervals.length; i++) {
+      String graphName = this.graphNames[i];
+      long graphInterval = this.graphIntervals[i];
+      long dataPointInterval = this.dataPointIntervals[i];
+      List<Long> dataPoints = new ArrayList<Long>();
+      long intervalStartMillis = ((this.now - graphInterval)
+          / dataPointInterval) * dataPointInterval;
+      long totalMillis = 0L, totalBandwidth = 0L;
+      for (long[] v : history.values()) {
+        long startMillis = v[0], endMillis = v[1], bandwidth = v[2];
+        if (endMillis < intervalStartMillis) {
+          continue;
+        }
+        while ((intervalStartMillis / dataPointInterval) !=
+            (endMillis / dataPointInterval)) {
+          dataPoints.add(totalMillis * 5L < dataPointInterval
+              ? -1L : (totalBandwidth * DateTimeHelper.ONE_SECOND)
+              / totalMillis);
+          totalBandwidth = 0L;
+          totalMillis = 0L;
+          intervalStartMillis += dataPointInterval;
+        }
+        totalBandwidth += bandwidth;
+        totalMillis += (endMillis - startMillis);
+      }
+      dataPoints.add(totalMillis * 5L < dataPointInterval
+          ? -1L : (totalBandwidth * DateTimeHelper.ONE_SECOND)
+          / totalMillis);
+      long maxValue = 1L;
+      int firstNonNullIndex = -1, lastNonNullIndex = -1;
+      for (int j = 0; j < dataPoints.size(); j++) {
+        long dataPoint = dataPoints.get(j);
+        if (dataPoint >= 0L) {
+          if (firstNonNullIndex < 0) {
+            firstNonNullIndex = j;
+          }
+          lastNonNullIndex = j;
+          if (dataPoint > maxValue) {
+            maxValue = dataPoint;
+          }
+        }
+      }
+      if (firstNonNullIndex < 0) {
+        continue;
+      }
+      long firstDataPointMillis = (((this.now - graphInterval)
+          / dataPointInterval) + firstNonNullIndex) * dataPointInterval
+          + dataPointInterval / 2L;
+      if (i > 0 &&
+          firstDataPointMillis >= this.now - graphIntervals[i - 1]) {
+        /* Skip bandwidth history object, because it doesn't contain
+         * anything new that wasn't already contained in the last
+         * bandwidth history object(s). */
+        continue;
+      }
+      long lastDataPointMillis = firstDataPointMillis
+          + (lastNonNullIndex - firstNonNullIndex) * dataPointInterval;
+      double factor = ((double) maxValue) / 999.0;
+      int count = lastNonNullIndex - firstNonNullIndex + 1;
+      GraphHistory graphHistory = new GraphHistory();
+      graphHistory.setFirst(DateTimeHelper.format(firstDataPointMillis));
+      graphHistory.setLast(DateTimeHelper.format(lastDataPointMillis));
+      graphHistory.setInterval((int) (dataPointInterval
+          / DateTimeHelper.ONE_SECOND));
+      graphHistory.setFactor(factor);
+      graphHistory.setCount(count);
+      int previousNonNullIndex = -2;
+      boolean foundTwoAdjacentDataPoints = false;
+      List<Integer> values = new ArrayList<Integer>();
+      for (int j = firstNonNullIndex; j <= lastNonNullIndex; j++) {
+        long dataPoint = dataPoints.get(j);
+        if (dataPoint >= 0L) {
+          if (j - previousNonNullIndex == 1) {
+            foundTwoAdjacentDataPoints = true;
+          }
+          previousNonNullIndex = j;
+        }
+        values.add(dataPoint < 0L ? null :
+          (int) ((dataPoint * 999L) / maxValue));
+      }
+      graphHistory.setValues(values);
+      if (foundTwoAdjacentDataPoints) {
+        graphs.put(graphName, graphHistory);
+      }
+    }
+    return graphs;
+  }
+
+  public String getStatsString() {
+    /* TODO Add statistics string. */
+    return null;
+  }
+}
diff --git a/src/org/torproject/onionoo/writer/ClientsDocumentWriter.java b/src/org/torproject/onionoo/writer/ClientsDocumentWriter.java
new file mode 100644
index 0000000..976804c
--- /dev/null
+++ b/src/org/torproject/onionoo/writer/ClientsDocumentWriter.java
@@ -0,0 +1,296 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.writer;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.torproject.onionoo.docs.ClientsDocument;
+import org.torproject.onionoo.docs.ClientsGraphHistory;
+import org.torproject.onionoo.docs.ClientsHistory;
+import org.torproject.onionoo.docs.ClientsStatus;
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.updater.DescriptorSource;
+import org.torproject.onionoo.updater.DescriptorType;
+import org.torproject.onionoo.updater.FingerprintListener;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Logger;
+
+/*
+ * Clients status file produced as intermediate output:
+ *
+ * 2014-02-15 16:42:11 2014-02-16 00:00:00
+ *   259.042 in=86.347,se=86.347  v4=259.042
+ * 2014-02-16 00:00:00 2014-02-16 16:42:11
+ *   592.958 in=197.653,se=197.653  v4=592.958
+ *
+ * Clients document file produced as output:
+ *
+ * "1_month":{
+ *   "first":"2014-02-03 12:00:00",
+ *   "last":"2014-02-28 12:00:00",
+ *   "interval":86400,
+ *   "factor":0.139049349,
+ *   "count":26,
+ *   "values":[371,354,349,374,432,null,485,458,493,536,null,null,524,576,
+ *             607,622,null,635,null,566,774,999,945,690,656,681],
+ *   "countries":{"cn":0.0192,"in":0.1768,"ir":0.2487,"ru":0.0104,
+ *                "se":0.1698,"sy":0.0325,"us":0.0406},
+ *   "transports":{"obfs2":0.4581},
+ *   "versions":{"v4":1.0000}}
+ */
+public class ClientsDocumentWriter implements FingerprintListener,
+    DocumentWriter {
+
+  private DescriptorSource descriptorSource;
+
+  private DocumentStore documentStore;
+
+  private long now;
+
+  public ClientsDocumentWriter() {
+    this.descriptorSource = ApplicationFactory.getDescriptorSource();
+    this.documentStore = ApplicationFactory.getDocumentStore();
+    this.now = ApplicationFactory.getTime().currentTimeMillis();
+    this.registerFingerprintListeners();
+  }
+
+  private void registerFingerprintListeners() {
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.BRIDGE_EXTRA_INFOS);
+  }
+
+  private SortedSet<String> updateDocuments = new TreeSet<String>();
+
+  public void processFingerprints(SortedSet<String> fingerprints,
+      boolean relay) {
+    if (!relay) {
+      this.updateDocuments.addAll(fingerprints);
+    }
+  }
+
+  private int writtenDocuments = 0;
+
+  public void writeDocuments() {
+    for (String hashedFingerprint : this.updateDocuments) {
+      ClientsStatus clientsStatus = this.documentStore.retrieve(
+          ClientsStatus.class, true, hashedFingerprint);
+      if (clientsStatus == null) {
+        continue;
+      }
+      SortedSet<ClientsHistory> history = clientsStatus.getHistory();
+      ClientsDocument clientsDocument = this.compileClientsDocument(
+          hashedFingerprint, history);
+      this.documentStore.store(clientsDocument, hashedFingerprint);
+      this.writtenDocuments++;
+    }
+    Logger.printStatusTime("Wrote clients document files");
+  }
+
+  private String[] graphNames = new String[] {
+      "1_week",
+      "1_month",
+      "3_months",
+      "1_year",
+      "5_years" };
+
+  private long[] graphIntervals = new long[] {
+      DateTimeHelper.ONE_WEEK,
+      DateTimeHelper.ROUGHLY_ONE_MONTH,
+      DateTimeHelper.ROUGHLY_THREE_MONTHS,
+      DateTimeHelper.ROUGHLY_ONE_YEAR,
+      DateTimeHelper.ROUGHLY_FIVE_YEARS };
+
+  private long[] dataPointIntervals = new long[] {
+      DateTimeHelper.ONE_DAY,
+      DateTimeHelper.ONE_DAY,
+      DateTimeHelper.ONE_DAY,
+      DateTimeHelper.TWO_DAYS,
+      DateTimeHelper.TEN_DAYS };
+
+  private ClientsDocument compileClientsDocument(String hashedFingerprint,
+      SortedSet<ClientsHistory> history) {
+    ClientsDocument clientsDocument = new ClientsDocument();
+    clientsDocument.setFingerprint(hashedFingerprint);
+    Map<String, ClientsGraphHistory> averageClients =
+        new LinkedHashMap<String, ClientsGraphHistory>();
+    for (int graphIntervalIndex = 0; graphIntervalIndex <
+        this.graphIntervals.length; graphIntervalIndex++) {
+      String graphName = this.graphNames[graphIntervalIndex];
+      ClientsGraphHistory graphHistory = this.compileClientsHistory(
+          graphIntervalIndex, history);
+      if (graphHistory != null) {
+        averageClients.put(graphName, graphHistory);
+      }
+    }
+    clientsDocument.setAverageClients(averageClients);
+    return clientsDocument;
+  }
+
+  private ClientsGraphHistory compileClientsHistory(
+      int graphIntervalIndex, SortedSet<ClientsHistory> history) {
+    long graphInterval = this.graphIntervals[graphIntervalIndex];
+    long dataPointInterval =
+        this.dataPointIntervals[graphIntervalIndex];
+    List<Double> dataPoints = new ArrayList<Double>();
+    long intervalStartMillis = ((this.now - graphInterval)
+        / dataPointInterval) * dataPointInterval;
+    long millis = 0L;
+    double responses = 0.0, totalResponses = 0.0;
+    SortedMap<String, Double>
+        totalResponsesByCountry = new TreeMap<String, Double>(),
+        totalResponsesByTransport = new TreeMap<String, Double>(),
+        totalResponsesByVersion = new TreeMap<String, Double>();
+    for (ClientsHistory hist : history) {
+      if (hist.getEndMillis() < intervalStartMillis) {
+        continue;
+      }
+      while ((intervalStartMillis / dataPointInterval) !=
+          (hist.getEndMillis() / dataPointInterval)) {
+        dataPoints.add(millis * 2L < dataPointInterval
+            ? -1.0 : responses * ((double) DateTimeHelper.ONE_DAY)
+            / (((double) millis) * 10.0));
+        responses = 0.0;
+        millis = 0L;
+        intervalStartMillis += dataPointInterval;
+      }
+      responses += hist.getTotalResponses();
+      totalResponses += hist.getTotalResponses();
+      for (Map.Entry<String, Double> e :
+          hist.getResponsesByCountry().entrySet()) {
+        if (!totalResponsesByCountry.containsKey(e.getKey())) {
+          totalResponsesByCountry.put(e.getKey(), 0.0);
+        }
+        totalResponsesByCountry.put(e.getKey(), e.getValue()
+            + totalResponsesByCountry.get(e.getKey()));
+      }
+      for (Map.Entry<String, Double> e :
+          hist.getResponsesByTransport().entrySet()) {
+        if (!totalResponsesByTransport.containsKey(e.getKey())) {
+          totalResponsesByTransport.put(e.getKey(), 0.0);
+        }
+        totalResponsesByTransport.put(e.getKey(), e.getValue()
+            + totalResponsesByTransport.get(e.getKey()));
+      }
+      for (Map.Entry<String, Double> e :
+          hist.getResponsesByVersion().entrySet()) {
+        if (!totalResponsesByVersion.containsKey(e.getKey())) {
+          totalResponsesByVersion.put(e.getKey(), 0.0);
+        }
+        totalResponsesByVersion.put(e.getKey(), e.getValue()
+            + totalResponsesByVersion.get(e.getKey()));
+      }
+      millis += (hist.getEndMillis() - hist.getStartMillis());
+    }
+    dataPoints.add(millis * 2L < dataPointInterval
+        ? -1.0 : responses * ((double) DateTimeHelper.ONE_DAY)
+        / (((double) millis) * 10.0));
+    double maxValue = 0.0;
+    int firstNonNullIndex = -1, lastNonNullIndex = -1;
+    for (int dataPointIndex = 0; dataPointIndex < dataPoints.size();
+        dataPointIndex++) {
+      double dataPoint = dataPoints.get(dataPointIndex);
+      if (dataPoint >= 0.0) {
+        if (firstNonNullIndex < 0) {
+          firstNonNullIndex = dataPointIndex;
+        }
+        lastNonNullIndex = dataPointIndex;
+        if (dataPoint > maxValue) {
+          maxValue = dataPoint;
+        }
+      }
+    }
+    if (firstNonNullIndex < 0) {
+      return null;
+    }
+    long firstDataPointMillis = (((this.now - graphInterval)
+        / dataPointInterval) + firstNonNullIndex) * dataPointInterval
+        + dataPointInterval / 2L;
+    if (graphIntervalIndex > 0 && firstDataPointMillis >=
+        this.now - graphIntervals[graphIntervalIndex - 1]) {
+      /* Skip clients history object, because it doesn't contain
+       * anything new that wasn't already contained in the last
+       * clients history object(s). */
+      return null;
+    }
+    long lastDataPointMillis = firstDataPointMillis
+        + (lastNonNullIndex - firstNonNullIndex) * dataPointInterval;
+    double factor = ((double) maxValue) / 999.0;
+    int count = lastNonNullIndex - firstNonNullIndex + 1;
+    ClientsGraphHistory graphHistory = new ClientsGraphHistory();
+    graphHistory.setFirst(DateTimeHelper.format(firstDataPointMillis));
+    graphHistory.setLast(DateTimeHelper.format(lastDataPointMillis));
+    graphHistory.setInterval((int) (dataPointInterval
+        / DateTimeHelper.ONE_SECOND));
+    graphHistory.setFactor(factor);
+    graphHistory.setCount(count);
+    int previousNonNullIndex = -2;
+    boolean foundTwoAdjacentDataPoints = false;
+    List<Integer> values = new ArrayList<Integer>();
+    for (int dataPointIndex = firstNonNullIndex; dataPointIndex <=
+        lastNonNullIndex; dataPointIndex++) {
+      double dataPoint = dataPoints.get(dataPointIndex);
+      if (dataPoint >= 0.0) {
+        if (dataPointIndex - previousNonNullIndex == 1) {
+          foundTwoAdjacentDataPoints = true;
+        }
+        previousNonNullIndex = dataPointIndex;
+      }
+      values.add(dataPoint < 0.0 ? null :
+          (int) ((dataPoint * 999.0) / maxValue));
+    }
+    graphHistory.setValues(values);
+    if (!totalResponsesByCountry.isEmpty()) {
+      SortedMap<String, Float> countries = new TreeMap<String, Float>();
+      for (Map.Entry<String, Double> e :
+          totalResponsesByCountry.entrySet()) {
+        if (e.getValue() > totalResponses / 100.0) {
+          countries.put(e.getKey(),
+              (float) (e.getValue() / totalResponses));
+        }
+      }
+      graphHistory.setCountries(countries);
+    }
+    if (!totalResponsesByTransport.isEmpty()) {
+      SortedMap<String, Float> transports = new TreeMap<String, Float>();
+      for (Map.Entry<String, Double> e :
+          totalResponsesByTransport.entrySet()) {
+        if (e.getValue() > totalResponses / 100.0) {
+          transports.put(e.getKey(),
+              (float) (e.getValue() / totalResponses));
+        }
+      }
+      graphHistory.setTransports(transports);
+    }
+    if (!totalResponsesByVersion.isEmpty()) {
+      SortedMap<String, Float> versions = new TreeMap<String, Float>();
+      for (Map.Entry<String, Double> e :
+          totalResponsesByVersion.entrySet()) {
+        if (e.getValue() > totalResponses / 100.0) {
+          versions.put(e.getKey(),
+              (float) (e.getValue() / totalResponses));
+        }
+      }
+      graphHistory.setVersions(versions);
+    }
+    if (foundTwoAdjacentDataPoints) {
+      return graphHistory;
+    } else {
+      return null;
+    }
+  }
+
+  public String getStatsString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("    " + Logger.formatDecimalNumber(this.writtenDocuments)
+        + " clients document files updated\n");
+    return sb.toString();
+  }
+}
diff --git a/src/org/torproject/onionoo/writer/DetailsDocumentWriter.java b/src/org/torproject/onionoo/writer/DetailsDocumentWriter.java
new file mode 100644
index 0000000..03f7024
--- /dev/null
+++ b/src/org/torproject/onionoo/writer/DetailsDocumentWriter.java
@@ -0,0 +1,233 @@
+package org.torproject.onionoo.writer;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import org.torproject.onionoo.docs.DetailsDocument;
+import org.torproject.onionoo.docs.DetailsStatus;
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.docs.NodeStatus;
+import org.torproject.onionoo.updater.DescriptorSource;
+import org.torproject.onionoo.updater.DescriptorType;
+import org.torproject.onionoo.updater.FingerprintListener;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Logger;
+
+public class DetailsDocumentWriter implements FingerprintListener,
+    DocumentWriter {
+
+  private DescriptorSource descriptorSource;
+
+  private DocumentStore documentStore;
+
+  private long now;
+
+  public DetailsDocumentWriter() {
+    this.descriptorSource = ApplicationFactory.getDescriptorSource();
+    this.documentStore = ApplicationFactory.getDocumentStore();
+    this.now = ApplicationFactory.getTime().currentTimeMillis();
+    this.registerFingerprintListeners();
+  }
+
+  private void registerFingerprintListeners() {
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.RELAY_CONSENSUSES);
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.RELAY_SERVER_DESCRIPTORS);
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.BRIDGE_STATUSES);
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.BRIDGE_SERVER_DESCRIPTORS);
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.BRIDGE_POOL_ASSIGNMENTS);
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.EXIT_LISTS);
+  }
+
+  private SortedSet<String> newRelays = new TreeSet<String>(),
+      newBridges = new TreeSet<String>();
+
+  public void processFingerprints(SortedSet<String> fingerprints,
+      boolean relay) {
+    if (relay) {
+      this.newRelays.addAll(fingerprints);
+    } else {
+      this.newBridges.addAll(fingerprints);
+    }
+  }
+
+  public void writeDocuments() {
+    this.updateRelayDetailsFiles();
+    this.updateBridgeDetailsFiles();
+    Logger.printStatusTime("Wrote details document files");
+  }
+
+  private void updateRelayDetailsFiles() {
+    for (String fingerprint : this.newRelays) {
+
+      /* Generate network-status-specific part. */
+      NodeStatus entry = this.documentStore.retrieve(NodeStatus.class,
+          true, fingerprint);
+      if (entry == null) {
+        continue;
+      }
+      DetailsDocument detailsDocument = new DetailsDocument();
+      detailsDocument.setNickname(entry.getNickname());
+      detailsDocument.setFingerprint(fingerprint);
+      List<String> orAddresses = new ArrayList<String>();
+      orAddresses.add(entry.getAddress() + ":" + entry.getOrPort());
+      for (String orAddress : entry.getOrAddressesAndPorts()) {
+        orAddresses.add(orAddress.toLowerCase());
+      }
+      detailsDocument.setOrAddresses(orAddresses);
+      if (entry.getDirPort() != 0) {
+        detailsDocument.setDirAddress(entry.getAddress() + ":"
+            + entry.getDirPort());
+      }
+      detailsDocument.setLastSeen(DateTimeHelper.format(
+          entry.getLastSeenMillis()));
+      detailsDocument.setFirstSeen(DateTimeHelper.format(
+          entry.getFirstSeenMillis()));
+      detailsDocument.setLastChangedAddressOrPort(
+          DateTimeHelper.format(entry.getLastChangedOrAddress()));
+      detailsDocument.setRunning(entry.getRunning());
+      if (!entry.getRelayFlags().isEmpty()) {
+        detailsDocument.setFlags(new ArrayList<String>(
+            entry.getRelayFlags()));
+      }
+      detailsDocument.setCountry(entry.getCountryCode());
+      detailsDocument.setLatitude(entry.getLatitude());
+      detailsDocument.setLongitude(entry.getLongitude());
+      detailsDocument.setCountryName(entry.getCountryName());
+      detailsDocument.setRegionName(entry.getRegionName());
+      detailsDocument.setCityName(entry.getCityName());
+      detailsDocument.setAsNumber(entry.getASNumber());
+      detailsDocument.setAsName(entry.getASName());
+      detailsDocument.setConsensusWeight(entry.getConsensusWeight());
+      detailsDocument.setHostName(entry.getHostName());
+      detailsDocument.setAdvertisedBandwidthFraction(
+          (float) entry.getAdvertisedBandwidthFraction());
+      detailsDocument.setConsensusWeightFraction(
+          (float) entry.getConsensusWeightFraction());
+      detailsDocument.setGuardProbability(
+          (float) entry.getGuardProbability());
+      detailsDocument.setMiddleProbability(
+          (float) entry.getMiddleProbability());
+      detailsDocument.setExitProbability(
+          (float) entry.getExitProbability());
+      String defaultPolicy = entry.getDefaultPolicy();
+      String portList = entry.getPortList();
+      if (defaultPolicy != null && (defaultPolicy.equals("accept") ||
+          defaultPolicy.equals("reject")) && portList != null) {
+        Map<String, List<String>> exitPolicySummary =
+            new HashMap<String, List<String>>();
+        List<String> portsOrPortRanges = Arrays.asList(
+            portList.split(","));
+        exitPolicySummary.put(defaultPolicy, portsOrPortRanges);
+        detailsDocument.setExitPolicySummary(exitPolicySummary);
+      }
+      detailsDocument.setRecommendedVersion(
+          entry.getRecommendedVersion());
+
+      /* Append descriptor-specific part and exit addresses from details
+       * status file. */
+      DetailsStatus detailsStatus = this.documentStore.retrieve(
+          DetailsStatus.class, true, fingerprint);
+      if (detailsStatus != null) {
+        detailsDocument.setLastRestarted(
+            detailsStatus.getLastRestarted());
+        detailsDocument.setBandwidthRate(
+            detailsStatus.getBandwidthRate());
+        detailsDocument.setBandwidthBurst(
+            detailsStatus.getBandwidthBurst());
+        detailsDocument.setObservedBandwidth(
+            detailsStatus.getObservedBandwidth());
+        detailsDocument.setAdvertisedBandwidth(
+            detailsStatus.getAdvertisedBandwidth());
+        detailsDocument.setExitPolicy(detailsStatus.getExitPolicy());
+        detailsDocument.setContact(detailsStatus.getContact());
+        detailsDocument.setPlatform(detailsStatus.getPlatform());
+        detailsDocument.setFamily(detailsStatus.getFamily());
+        detailsDocument.setExitPolicyV6Summary(
+            detailsStatus.getExitPolicyV6Summary());
+        detailsDocument.setHibernating(detailsStatus.getHibernating());
+        if (detailsStatus.getExitAddresses() != null) {
+          SortedSet<String> exitAddresses = new TreeSet<String>();
+          for (Map.Entry<String, Long> e :
+              detailsStatus.getExitAddresses().entrySet()) {
+            String exitAddress = e.getKey().toLowerCase();
+            long scanMillis = e.getValue();
+            if (!entry.getAddress().equals(exitAddress) &&
+                !entry.getOrAddresses().contains(exitAddress) &&
+                scanMillis >= this.now - DateTimeHelper.ONE_DAY) {
+              exitAddresses.add(exitAddress);
+            }
+          }
+          if (!exitAddresses.isEmpty()) {
+            detailsDocument.setExitAddresses(new ArrayList<String>(
+                exitAddresses));
+          }
+        }
+      }
+
+      /* Write details file to disk. */
+      this.documentStore.store(detailsDocument, fingerprint);
+    }
+  }
+
+  private void updateBridgeDetailsFiles() {
+    for (String fingerprint : this.newBridges) {
+
+      /* Generate network-status-specific part. */
+      NodeStatus entry = this.documentStore.retrieve(NodeStatus.class,
+          true, fingerprint);
+      if (entry == null) {
+        continue;
+      }
+      DetailsDocument detailsDocument = new DetailsDocument();
+      detailsDocument.setNickname(entry.getNickname());
+      detailsDocument.setHashedFingerprint(fingerprint);
+      String address = entry.getAddress();
+      List<String> orAddresses = new ArrayList<String>();
+      orAddresses.add(address + ":" + entry.getOrPort());
+      for (String orAddress : entry.getOrAddressesAndPorts()) {
+        orAddresses.add(orAddress.toLowerCase());
+      }
+      detailsDocument.setOrAddresses(orAddresses);
+      detailsDocument.setLastSeen(DateTimeHelper.format(
+          entry.getLastSeenMillis()));
+      detailsDocument.setFirstSeen(DateTimeHelper.format(
+          entry.getFirstSeenMillis()));
+      detailsDocument.setRunning(entry.getRunning());
+      detailsDocument.setFlags(new ArrayList<String>(
+          entry.getRelayFlags()));
+
+      /* Append descriptor-specific part from details status file. */
+      DetailsStatus detailsStatus = this.documentStore.retrieve(
+          DetailsStatus.class, true, fingerprint);
+      if (detailsStatus != null) {
+        detailsDocument.setLastRestarted(
+            detailsStatus.getLastRestarted());
+        detailsDocument.setAdvertisedBandwidth(
+            detailsStatus.getAdvertisedBandwidth());
+        detailsDocument.setPlatform(detailsStatus.getPlatform());
+        detailsDocument.setPoolAssignment(
+            detailsStatus.getPoolAssignment());
+      }
+
+      /* Write details file to disk. */
+      this.documentStore.store(detailsDocument, fingerprint);
+    }
+  }
+
+  public String getStatsString() {
+    /* TODO Add statistics string. */
+    return null;
+  }
+}
diff --git a/src/org/torproject/onionoo/writer/DocumentWriter.java b/src/org/torproject/onionoo/writer/DocumentWriter.java
new file mode 100644
index 0000000..c238170
--- /dev/null
+++ b/src/org/torproject/onionoo/writer/DocumentWriter.java
@@ -0,0 +1,11 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.writer;
+
+public interface DocumentWriter {
+
+  public abstract void writeDocuments();
+
+  public abstract String getStatsString();
+}
+
diff --git a/src/org/torproject/onionoo/writer/SummaryDocumentWriter.java b/src/org/torproject/onionoo/writer/SummaryDocumentWriter.java
new file mode 100644
index 0000000..1b4630e
--- /dev/null
+++ b/src/org/torproject/onionoo/writer/SummaryDocumentWriter.java
@@ -0,0 +1,94 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.writer;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.SortedSet;
+
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.docs.NodeStatus;
+import org.torproject.onionoo.docs.SummaryDocument;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Logger;
+
+public class SummaryDocumentWriter implements DocumentWriter {
+
+  private DocumentStore documentStore;
+
+  public SummaryDocumentWriter() {
+    this.documentStore = ApplicationFactory.getDocumentStore();
+  }
+
+  private int writtenDocuments = 0, deletedDocuments = 0;
+
+  public void writeDocuments() {
+    long maxLastSeenMillis = 0L;
+    for (String fingerprint : this.documentStore.list(NodeStatus.class)) {
+      NodeStatus nodeStatus = this.documentStore.retrieve(
+          NodeStatus.class, true, fingerprint);
+      if (nodeStatus != null &&
+          nodeStatus.getLastSeenMillis() > maxLastSeenMillis) {
+        maxLastSeenMillis = nodeStatus.getLastSeenMillis();
+      }
+    }
+    long cutoff = maxLastSeenMillis - DateTimeHelper.ONE_WEEK;
+    for (String fingerprint : this.documentStore.list(NodeStatus.class)) {
+      NodeStatus nodeStatus = this.documentStore.retrieve(
+          NodeStatus.class,
+          true, fingerprint);
+      if (nodeStatus == null) {
+        continue;
+      }
+      if (nodeStatus.getLastSeenMillis() < cutoff) {
+        if (this.documentStore.remove(SummaryDocument.class,
+            fingerprint)) {
+          this.deletedDocuments++;
+        }
+        continue;
+      }
+      boolean isRelay = nodeStatus.isRelay();
+      String nickname = nodeStatus.getNickname();
+      List<String> addresses = new ArrayList<String>();
+      addresses.add(nodeStatus.getAddress());
+      for (String orAddress : nodeStatus.getOrAddresses()) {
+        if (!addresses.contains(orAddress)) {
+          addresses.add(orAddress);
+        }
+      }
+      for (String exitAddress : nodeStatus.getExitAddresses()) {
+        if (!addresses.contains(exitAddress)) {
+          addresses.add(exitAddress);
+        }
+      }
+      long lastSeenMillis = nodeStatus.getLastSeenMillis();
+      boolean running = nodeStatus.getRunning();
+      SortedSet<String> relayFlags = nodeStatus.getRelayFlags();
+      long consensusWeight = nodeStatus.getConsensusWeight();
+      String countryCode = nodeStatus.getCountryCode();
+      long firstSeenMillis = nodeStatus.getFirstSeenMillis();
+      String aSNumber = nodeStatus.getASNumber();
+      String contact = nodeStatus.getContact();
+      SortedSet<String> familyFingerprints =
+          nodeStatus.getFamilyFingerprints();
+      SummaryDocument summaryDocument = new SummaryDocument(isRelay,
+          nickname, fingerprint, addresses, lastSeenMillis, running,
+          relayFlags, consensusWeight, countryCode, firstSeenMillis,
+          aSNumber, contact, familyFingerprints);
+      if (this.documentStore.store(summaryDocument, fingerprint)) {
+        this.writtenDocuments++;
+      };
+    }
+    Logger.printStatusTime("Wrote summary document files");
+  }
+
+  public String getStatsString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("    " + Logger.formatDecimalNumber(this.writtenDocuments)
+        + " summary document files written\n");
+    sb.append("    " + Logger.formatDecimalNumber(this.deletedDocuments)
+        + " summary document files deleted\n");
+    return sb.toString();
+  }
+}
diff --git a/src/org/torproject/onionoo/writer/UptimeDocumentWriter.java b/src/org/torproject/onionoo/writer/UptimeDocumentWriter.java
new file mode 100644
index 0000000..3e04abb
--- /dev/null
+++ b/src/org/torproject/onionoo/writer/UptimeDocumentWriter.java
@@ -0,0 +1,303 @@
+/* Copyright 2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.writer;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.docs.GraphHistory;
+import org.torproject.onionoo.docs.UptimeDocument;
+import org.torproject.onionoo.docs.UptimeHistory;
+import org.torproject.onionoo.docs.UptimeStatus;
+import org.torproject.onionoo.updater.DescriptorSource;
+import org.torproject.onionoo.updater.DescriptorType;
+import org.torproject.onionoo.updater.FingerprintListener;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Logger;
+
+public class UptimeDocumentWriter implements FingerprintListener,
+    DocumentWriter {
+
+  private DescriptorSource descriptorSource;
+
+  private DocumentStore documentStore;
+
+  private long now;
+
+  public UptimeDocumentWriter() {
+    this.descriptorSource = ApplicationFactory.getDescriptorSource();
+    this.documentStore = ApplicationFactory.getDocumentStore();
+    this.now = ApplicationFactory.getTime().currentTimeMillis();
+    this.registerFingerprintListeners();
+  }
+
+  private void registerFingerprintListeners() {
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.RELAY_CONSENSUSES);
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.BRIDGE_STATUSES);
+  }
+
+  private SortedSet<String> newRelayFingerprints = new TreeSet<String>(),
+      newBridgeFingerprints = new TreeSet<String>();
+
+  public void processFingerprints(SortedSet<String> fingerprints,
+      boolean relay) {
+    if (relay) {
+      this.newRelayFingerprints.addAll(fingerprints);
+    } else {
+      this.newBridgeFingerprints.addAll(fingerprints);
+    }
+  }
+
+  public void writeDocuments() {
+    UptimeStatus uptimeStatus = this.documentStore.retrieve(
+        UptimeStatus.class, true);
+    if (uptimeStatus == null) {
+      return;
+    }
+    for (String fingerprint : this.newRelayFingerprints) {
+      this.updateDocument(true, fingerprint,
+          uptimeStatus.getRelayHistory());
+    }
+    for (String fingerprint : this.newBridgeFingerprints) {
+      this.updateDocument(false, fingerprint,
+          uptimeStatus.getBridgeHistory());
+    }
+    Logger.printStatusTime("Wrote uptime document files");
+  }
+
+  private int writtenDocuments = 0;
+
+  private void updateDocument(boolean relay, String fingerprint,
+      SortedSet<UptimeHistory> knownStatuses) {
+    UptimeStatus uptimeStatus = this.documentStore.retrieve(
+        UptimeStatus.class, true, fingerprint);
+    if (uptimeStatus != null) {
+      SortedSet<UptimeHistory> history = relay
+          ? uptimeStatus.getRelayHistory()
+          : uptimeStatus.getBridgeHistory();
+      UptimeDocument uptimeDocument = this.compileUptimeDocument(relay,
+          fingerprint, history, knownStatuses);
+      this.documentStore.store(uptimeDocument, fingerprint);
+      this.writtenDocuments++;
+    }
+  }
+
+  private String[] graphNames = new String[] {
+      "1_week",
+      "1_month",
+      "3_months",
+      "1_year",
+      "5_years" };
+
+  private long[] graphIntervals = new long[] {
+      DateTimeHelper.ONE_WEEK,
+      DateTimeHelper.ROUGHLY_ONE_MONTH,
+      DateTimeHelper.ROUGHLY_THREE_MONTHS,
+      DateTimeHelper.ROUGHLY_ONE_YEAR,
+      DateTimeHelper.ROUGHLY_FIVE_YEARS };
+
+  private long[] dataPointIntervals = new long[] {
+      DateTimeHelper.ONE_HOUR,
+      DateTimeHelper.FOUR_HOURS,
+      DateTimeHelper.TWELVE_HOURS,
+      DateTimeHelper.TWO_DAYS,
+      DateTimeHelper.TEN_DAYS };
+
+  private UptimeDocument compileUptimeDocument(boolean relay,
+      String fingerprint, SortedSet<UptimeHistory> history,
+      SortedSet<UptimeHistory> knownStatuses) {
+    UptimeDocument uptimeDocument = new UptimeDocument();
+    uptimeDocument.setFingerprint(fingerprint);
+    Map<String, GraphHistory> uptime =
+        new LinkedHashMap<String, GraphHistory>();
+    for (int graphIntervalIndex = 0; graphIntervalIndex <
+        this.graphIntervals.length; graphIntervalIndex++) {
+      String graphName = this.graphNames[graphIntervalIndex];
+      GraphHistory graphHistory = this.compileUptimeHistory(
+          graphIntervalIndex, relay, history, knownStatuses);
+      if (graphHistory != null) {
+        uptime.put(graphName, graphHistory);
+      }
+    }
+    uptimeDocument.setUptime(uptime);
+    return uptimeDocument;
+  }
+
+  private GraphHistory compileUptimeHistory(int graphIntervalIndex,
+      boolean relay, SortedSet<UptimeHistory> history,
+      SortedSet<UptimeHistory> knownStatuses) {
+    long graphInterval = this.graphIntervals[graphIntervalIndex];
+    long dataPointInterval =
+        this.dataPointIntervals[graphIntervalIndex];
+    int dataPointIntervalHours = (int) (dataPointInterval
+        / DateTimeHelper.ONE_HOUR);
+    List<Integer> uptimeDataPoints = new ArrayList<Integer>();
+    long intervalStartMillis = ((this.now - graphInterval)
+        / dataPointInterval) * dataPointInterval;
+    int uptimeHours = 0;
+    long firstStatusStartMillis = -1L;
+    for (UptimeHistory hist : history) {
+      if (hist.isRelay() != relay) {
+        continue;
+      }
+      if (firstStatusStartMillis < 0L) {
+        firstStatusStartMillis = hist.getStartMillis();
+      }
+      long histEndMillis = hist.getStartMillis() + DateTimeHelper.ONE_HOUR
+          * hist.getUptimeHours();
+      if (histEndMillis < intervalStartMillis) {
+        continue;
+      }
+      while (hist.getStartMillis() >= intervalStartMillis
+          + dataPointInterval) {
+        if (firstStatusStartMillis < intervalStartMillis
+            + dataPointInterval) {
+          uptimeDataPoints.add(uptimeHours);
+        } else {
+          uptimeDataPoints.add(-1);
+        }
+        uptimeHours = 0;
+        intervalStartMillis += dataPointInterval;
+      }
+      while (histEndMillis >= intervalStartMillis + dataPointInterval) {
+        uptimeHours += (int) ((intervalStartMillis + dataPointInterval
+            - Math.max(hist.getStartMillis(), intervalStartMillis))
+            / DateTimeHelper.ONE_HOUR);
+        uptimeDataPoints.add(uptimeHours);
+        uptimeHours = 0;
+        intervalStartMillis += dataPointInterval;
+      }
+      uptimeHours += (int) ((histEndMillis - Math.max(
+          hist.getStartMillis(), intervalStartMillis))
+          / DateTimeHelper.ONE_HOUR);
+    }
+    uptimeDataPoints.add(uptimeHours);
+    List<Integer> statusDataPoints = new ArrayList<Integer>();
+    intervalStartMillis = ((this.now - graphInterval)
+        / dataPointInterval) * dataPointInterval;
+    int statusHours = -1;
+    for (UptimeHistory hist : knownStatuses) {
+      if (hist.isRelay() != relay) {
+        continue;
+      }
+      long histEndMillis = hist.getStartMillis() + DateTimeHelper.ONE_HOUR
+          * hist.getUptimeHours();
+      if (histEndMillis < intervalStartMillis) {
+        continue;
+      }
+      while (hist.getStartMillis() >= intervalStartMillis
+          + dataPointInterval) {
+        statusDataPoints.add(statusHours * 5 > dataPointIntervalHours
+            ? statusHours : -1);
+        statusHours = -1;
+        intervalStartMillis += dataPointInterval;
+      }
+      while (histEndMillis >= intervalStartMillis + dataPointInterval) {
+        if (statusHours < 0) {
+          statusHours = 0;
+        }
+        statusHours += (int) ((intervalStartMillis + dataPointInterval
+            - Math.max(Math.max(hist.getStartMillis(),
+            firstStatusStartMillis), intervalStartMillis))
+            / DateTimeHelper.ONE_HOUR);
+        statusDataPoints.add(statusHours * 5 > dataPointIntervalHours
+            ? statusHours : -1);
+        statusHours = -1;
+        intervalStartMillis += dataPointInterval;
+      }
+      if (statusHours < 0) {
+        statusHours = 0;
+      }
+      statusHours += (int) ((histEndMillis - Math.max(Math.max(
+          hist.getStartMillis(), firstStatusStartMillis),
+          intervalStartMillis)) / DateTimeHelper.ONE_HOUR);
+    }
+    if (statusHours > 0) {
+      statusDataPoints.add(statusHours * 5 > dataPointIntervalHours
+          ? statusHours : -1);
+    }
+    List<Double> dataPoints = new ArrayList<Double>();
+    for (int dataPointIndex = 0; dataPointIndex < statusDataPoints.size();
+        dataPointIndex++) {
+      if (dataPointIndex >= uptimeDataPoints.size()) {
+        dataPoints.add(0.0);
+      } else if (uptimeDataPoints.get(dataPointIndex) >= 0 &&
+          statusDataPoints.get(dataPointIndex) > 0) {
+        dataPoints.add(((double) uptimeDataPoints.get(dataPointIndex))
+            / ((double) statusDataPoints.get(dataPointIndex)));
+      } else {
+        dataPoints.add(-1.0);
+      }
+    }
+    int firstNonNullIndex = -1, lastNonNullIndex = -1;
+    for (int dataPointIndex = 0; dataPointIndex < dataPoints.size();
+        dataPointIndex++) {
+      double dataPoint = dataPoints.get(dataPointIndex);
+      if (dataPoint >= 0.0) {
+        if (firstNonNullIndex < 0) {
+          firstNonNullIndex = dataPointIndex;
+        }
+        lastNonNullIndex = dataPointIndex;
+      }
+    }
+    if (firstNonNullIndex < 0) {
+      return null;
+    }
+    long firstDataPointMillis = (((this.now - graphInterval)
+        / dataPointInterval) + firstNonNullIndex)
+        * dataPointInterval + dataPointInterval / 2L;
+    if (graphIntervalIndex > 0 && firstDataPointMillis >=
+        this.now - graphIntervals[graphIntervalIndex - 1]) {
+      /* Skip uptime history object, because it doesn't contain
+       * anything new that wasn't already contained in the last
+       * uptime history object(s). */
+      return null;
+    }
+    long lastDataPointMillis = firstDataPointMillis
+        + (lastNonNullIndex - firstNonNullIndex) * dataPointInterval;
+    int count = lastNonNullIndex - firstNonNullIndex + 1;
+    GraphHistory graphHistory = new GraphHistory();
+    graphHistory.setFirst(DateTimeHelper.format(firstDataPointMillis));
+    graphHistory.setLast(DateTimeHelper.format(lastDataPointMillis));
+    graphHistory.setInterval((int) (dataPointInterval
+        / DateTimeHelper.ONE_SECOND));
+    graphHistory.setFactor(1.0 / 999.0);
+    graphHistory.setCount(count);
+    int previousNonNullIndex = -2;
+    boolean foundTwoAdjacentDataPoints = false;
+    List<Integer> values = new ArrayList<Integer>();
+    for (int dataPointIndex = firstNonNullIndex; dataPointIndex <=
+        lastNonNullIndex; dataPointIndex++) {
+      double dataPoint = dataPoints.get(dataPointIndex);
+      if (dataPoint >= 0.0) {
+        if (dataPointIndex - previousNonNullIndex == 1) {
+          foundTwoAdjacentDataPoints = true;
+        }
+        previousNonNullIndex = dataPointIndex;
+      }
+      values.add(dataPoint < -0.5 ? null : ((int) (dataPoint * 999.0)));
+    }
+    graphHistory.setValues(values);
+    if (foundTwoAdjacentDataPoints) {
+      return graphHistory;
+    } else {
+      return null;
+    }
+  }
+
+  public String getStatsString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("    " + Logger.formatDecimalNumber(this.writtenDocuments)
+        + " uptime document files written\n");
+    return sb.toString();
+  }
+}
+
diff --git a/src/org/torproject/onionoo/writer/WeightsDocumentWriter.java b/src/org/torproject/onionoo/writer/WeightsDocumentWriter.java
new file mode 100644
index 0000000..ddd2774
--- /dev/null
+++ b/src/org/torproject/onionoo/writer/WeightsDocumentWriter.java
@@ -0,0 +1,233 @@
+/* Copyright 2012--2014 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.onionoo.writer;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+
+import org.torproject.onionoo.docs.DocumentStore;
+import org.torproject.onionoo.docs.GraphHistory;
+import org.torproject.onionoo.docs.WeightsDocument;
+import org.torproject.onionoo.docs.WeightsStatus;
+import org.torproject.onionoo.updater.DescriptorSource;
+import org.torproject.onionoo.updater.DescriptorType;
+import org.torproject.onionoo.updater.FingerprintListener;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Logger;
+
+public class WeightsDocumentWriter implements FingerprintListener,
+    DocumentWriter {
+
+  private DescriptorSource descriptorSource;
+
+  private DocumentStore documentStore;
+
+  private long now;
+
+  public WeightsDocumentWriter() {
+    this.descriptorSource = ApplicationFactory.getDescriptorSource();
+    this.documentStore = ApplicationFactory.getDocumentStore();
+    this.now = ApplicationFactory.getTime().currentTimeMillis();
+    this.registerFingerprintListeners();
+  }
+
+  private void registerFingerprintListeners() {
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.RELAY_CONSENSUSES);
+    this.descriptorSource.registerFingerprintListener(this,
+        DescriptorType.RELAY_SERVER_DESCRIPTORS);
+  }
+
+  private Set<String> updateWeightsDocuments = new HashSet<String>();
+
+  public void processFingerprints(SortedSet<String> fingerprints,
+      boolean relay) {
+    if (relay) {
+      this.updateWeightsDocuments.addAll(fingerprints);
+    }
+  }
+
+  public void writeDocuments() {
+    this.writeWeightsDataFiles();
+    Logger.printStatusTime("Wrote weights document files");
+  }
+
+  private void writeWeightsDataFiles() {
+    for (String fingerprint : this.updateWeightsDocuments) {
+      WeightsStatus weightsStatus = this.documentStore.retrieve(
+          WeightsStatus.class, true, fingerprint);
+      if (weightsStatus == null) {
+        continue;
+      }
+      SortedMap<long[], double[]> history = weightsStatus.getHistory();
+      WeightsDocument weightsDocument = this.compileWeightsDocument(
+          fingerprint, history);
+      this.documentStore.store(weightsDocument, fingerprint);
+    }
+    Logger.printStatusTime("Wrote weights document files");
+  }
+
+  private String[] graphNames = new String[] {
+      "1_week",
+      "1_month",
+      "3_months",
+      "1_year",
+      "5_years" };
+
+  private long[] graphIntervals = new long[] {
+      DateTimeHelper.ONE_WEEK,
+      DateTimeHelper.ROUGHLY_ONE_MONTH,
+      DateTimeHelper.ROUGHLY_THREE_MONTHS,
+      DateTimeHelper.ROUGHLY_ONE_YEAR,
+      DateTimeHelper.ROUGHLY_FIVE_YEARS };
+
+  private long[] dataPointIntervals = new long[] {
+      DateTimeHelper.ONE_HOUR,
+      DateTimeHelper.FOUR_HOURS,
+      DateTimeHelper.TWELVE_HOURS,
+      DateTimeHelper.TWO_DAYS,
+      DateTimeHelper.TEN_DAYS };
+
+  private WeightsDocument compileWeightsDocument(String fingerprint,
+      SortedMap<long[], double[]> history) {
+    WeightsDocument weightsDocument = new WeightsDocument();
+    weightsDocument.setFingerprint(fingerprint);
+    weightsDocument.setAdvertisedBandwidthFraction(
+        this.compileGraphType(history, 0));
+    weightsDocument.setConsensusWeightFraction(
+        this.compileGraphType(history, 1));
+    weightsDocument.setGuardProbability(
+        this.compileGraphType(history, 2));
+    weightsDocument.setMiddleProbability(
+        this.compileGraphType(history, 3));
+    weightsDocument.setExitProbability(
+        this.compileGraphType(history, 4));
+    weightsDocument.setAdvertisedBandwidth(
+        this.compileGraphType(history, 5));
+    weightsDocument.setConsensusWeight(
+        this.compileGraphType(history, 6));
+    return weightsDocument;
+  }
+
+  private Map<String, GraphHistory> compileGraphType(
+      SortedMap<long[], double[]> history, int graphTypeIndex) {
+    Map<String, GraphHistory> graphs =
+        new LinkedHashMap<String, GraphHistory>();
+    for (int graphIntervalIndex = 0; graphIntervalIndex <
+        this.graphIntervals.length; graphIntervalIndex++) {
+      String graphName = this.graphNames[graphIntervalIndex];
+      GraphHistory graphHistory = this.compileWeightsHistory(
+          graphTypeIndex, graphIntervalIndex, history);
+      if (graphHistory != null) {
+        graphs.put(graphName, graphHistory);
+      }
+    }
+    return graphs;
+  }
+
+  private GraphHistory compileWeightsHistory(int graphTypeIndex,
+      int graphIntervalIndex, SortedMap<long[], double[]> history) {
+    long graphInterval = this.graphIntervals[graphIntervalIndex];
+    long dataPointInterval =
+        this.dataPointIntervals[graphIntervalIndex];
+    List<Double> dataPoints = new ArrayList<Double>();
+    long intervalStartMillis = ((this.now - graphInterval)
+        / dataPointInterval) * dataPointInterval;
+    long totalMillis = 0L;
+    double totalWeightTimesMillis = 0.0;
+    for (Map.Entry<long[], double[]> e : history.entrySet()) {
+      long startMillis = e.getKey()[0], endMillis = e.getKey()[1];
+      double weight = e.getValue()[graphTypeIndex];
+      if (endMillis < intervalStartMillis) {
+        continue;
+      }
+      while ((intervalStartMillis / dataPointInterval) !=
+          (endMillis / dataPointInterval)) {
+        dataPoints.add(totalMillis * 5L < dataPointInterval
+            ? -1.0 : totalWeightTimesMillis / (double) totalMillis);
+        totalWeightTimesMillis = 0.0;
+        totalMillis = 0L;
+        intervalStartMillis += dataPointInterval;
+      }
+      if (weight >= 0.0) {
+        totalWeightTimesMillis += weight
+            * ((double) (endMillis - startMillis));
+        totalMillis += (endMillis - startMillis);
+      }
+    }
+    dataPoints.add(totalMillis * 5L < dataPointInterval
+        ? -1.0 : totalWeightTimesMillis / (double) totalMillis);
+    double maxValue = 0.0;
+    int firstNonNullIndex = -1, lastNonNullIndex = -1;
+    for (int dataPointIndex = 0; dataPointIndex < dataPoints.size();
+        dataPointIndex++) {
+      double dataPoint = dataPoints.get(dataPointIndex);
+      if (dataPoint >= 0.0) {
+        if (firstNonNullIndex < 0) {
+          firstNonNullIndex = dataPointIndex;
+        }
+        lastNonNullIndex = dataPointIndex;
+        if (dataPoint > maxValue) {
+          maxValue = dataPoint;
+        }
+      }
+    }
+    if (firstNonNullIndex < 0) {
+      return null;
+    }
+    long firstDataPointMillis = (((this.now - graphInterval)
+        / dataPointInterval) + firstNonNullIndex) * dataPointInterval
+        + dataPointInterval / 2L;
+    if (graphIntervalIndex > 0 && firstDataPointMillis >=
+        this.now - graphIntervals[graphIntervalIndex - 1]) {
+      /* Skip weights history object, because it doesn't contain
+       * anything new that wasn't already contained in the last
+       * weights history object(s). */
+      return null;
+    }
+    long lastDataPointMillis = firstDataPointMillis
+        + (lastNonNullIndex - firstNonNullIndex) * dataPointInterval;
+    double factor = ((double) maxValue) / 999.0;
+    int count = lastNonNullIndex - firstNonNullIndex + 1;
+    GraphHistory graphHistory = new GraphHistory();
+    graphHistory.setFirst(DateTimeHelper.format(firstDataPointMillis));
+    graphHistory.setLast(DateTimeHelper.format(lastDataPointMillis));
+    graphHistory.setInterval((int) (dataPointInterval
+        / DateTimeHelper.ONE_SECOND));
+    graphHistory.setFactor(factor);
+    graphHistory.setCount(count);
+    int previousNonNullIndex = -2;
+    boolean foundTwoAdjacentDataPoints = false;
+    List<Integer> values = new ArrayList<Integer>();
+    for (int dataPointIndex = firstNonNullIndex; dataPointIndex <=
+        lastNonNullIndex; dataPointIndex++) {
+      double dataPoint = dataPoints.get(dataPointIndex);
+      if (dataPoint >= 0.0) {
+        if (dataPointIndex - previousNonNullIndex == 1) {
+          foundTwoAdjacentDataPoints = true;
+        }
+        previousNonNullIndex = dataPointIndex;
+      }
+      values.add(dataPoint < 0.0 ? null :
+          (int) ((dataPoint * 999.0) / maxValue));
+    }
+    graphHistory.setValues(values);
+    if (foundTwoAdjacentDataPoints) {
+      return graphHistory;
+    } else {
+      return null;
+    }
+  }
+
+  public String getStatsString() {
+    /* TODO Add statistics string. */
+    return null;
+  }
+}
diff --git a/test/org/torproject/onionoo/DummyDescriptorSource.java b/test/org/torproject/onionoo/DummyDescriptorSource.java
index 06ec499..e93b063 100644
--- a/test/org/torproject/onionoo/DummyDescriptorSource.java
+++ b/test/org/torproject/onionoo/DummyDescriptorSource.java
@@ -9,6 +9,10 @@ import java.util.SortedSet;
 import java.util.TreeSet;
 
 import org.torproject.descriptor.Descriptor;
+import org.torproject.onionoo.updater.DescriptorListener;
+import org.torproject.onionoo.updater.DescriptorSource;
+import org.torproject.onionoo.updater.DescriptorType;
+import org.torproject.onionoo.updater.FingerprintListener;
 
 public class DummyDescriptorSource extends DescriptorSource {
 
diff --git a/test/org/torproject/onionoo/DummyDocumentStore.java b/test/org/torproject/onionoo/DummyDocumentStore.java
index 5a5905b..54311aa 100644
--- a/test/org/torproject/onionoo/DummyDocumentStore.java
+++ b/test/org/torproject/onionoo/DummyDocumentStore.java
@@ -7,6 +7,9 @@ import java.util.SortedSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
 
+import org.torproject.onionoo.docs.Document;
+import org.torproject.onionoo.docs.DocumentStore;
+
 public class DummyDocumentStore extends DocumentStore {
 
   private Map<Class<? extends Document>, SortedMap<String, Document>>
diff --git a/test/org/torproject/onionoo/DummyTime.java b/test/org/torproject/onionoo/DummyTime.java
index 7178ed1..ffbd6e3 100644
--- a/test/org/torproject/onionoo/DummyTime.java
+++ b/test/org/torproject/onionoo/DummyTime.java
@@ -2,6 +2,8 @@
  * See LICENSE for licensing information */
 package org.torproject.onionoo;
 
+import org.torproject.onionoo.util.Time;
+
 public class DummyTime extends Time {
   private long currentTimeMillis;
   public DummyTime(long currentTimeMillis) {
diff --git a/test/org/torproject/onionoo/LookupServiceTest.java b/test/org/torproject/onionoo/LookupServiceTest.java
index 23b5a91..052b4c0 100644
--- a/test/org/torproject/onionoo/LookupServiceTest.java
+++ b/test/org/torproject/onionoo/LookupServiceTest.java
@@ -22,6 +22,8 @@ import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
+import org.torproject.onionoo.updater.LookupResult;
+import org.torproject.onionoo.updater.LookupService;
 
 public class LookupServiceTest {
 
diff --git a/test/org/torproject/onionoo/ResourceServletTest.java b/test/org/torproject/onionoo/ResourceServletTest.java
index f2ace5d..8cd3752 100644
--- a/test/org/torproject/onionoo/ResourceServletTest.java
+++ b/test/org/torproject/onionoo/ResourceServletTest.java
@@ -21,8 +21,14 @@ import java.util.TreeSet;
 
 import org.junit.Before;
 import org.junit.Test;
-import org.torproject.onionoo.ResourceServlet.HttpServletRequestWrapper;
-import org.torproject.onionoo.ResourceServlet.HttpServletResponseWrapper;
+import org.torproject.onionoo.docs.UpdateStatus;
+import org.torproject.onionoo.server.NodeIndexer;
+import org.torproject.onionoo.server.ResourceServlet;
+import org.torproject.onionoo.server.ResourceServlet.HttpServletRequestWrapper;
+import org.torproject.onionoo.server.ResourceServlet.HttpServletResponseWrapper;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.util.Time;
 
 import com.google.gson.Gson;
 
@@ -31,7 +37,7 @@ import com.google.gson.Gson;
  * which tests servlet specifics. */
 public class ResourceServletTest {
 
-  private SortedMap<String, org.torproject.onionoo.SummaryDocument>
+  private SortedMap<String, org.torproject.onionoo.docs.SummaryDocument>
       relays, bridges;
 
   private long currentTimeMillis = DateTimeHelper.parse(
@@ -102,8 +108,8 @@ public class ResourceServletTest {
 
   @Before
   public void createSampleRelaysAndBridges() {
-    org.torproject.onionoo.SummaryDocument relayTorkaZ =
-        new org.torproject.onionoo.SummaryDocument(true, "TorkaZ",
+    org.torproject.onionoo.docs.SummaryDocument relayTorkaZ =
+        new org.torproject.onionoo.docs.SummaryDocument(true, "TorkaZ",
         "000C5F55BD4814B917CC474BD537F1A3B33CCE2A", Arrays.asList(
         new String[] { "62.216.201.221", "62.216.201.222",
         "62.216.201.223" }), DateTimeHelper.parse("2013-04-19 05:00:00"),
@@ -114,8 +120,8 @@ public class ResourceServletTest {
         + "<fb-token:np5_g_83jmf=>", new TreeSet<String>(Arrays.asList(
         new String[] { "001C13B3A55A71B977CA65EC85539D79C653A3FC",
         "0025C136C1F3A9EEFE2AE3F918F03BFA21B5070B" })));
-    org.torproject.onionoo.SummaryDocument relayFerrari458 =
-        new org.torproject.onionoo.SummaryDocument(true, "Ferrari458",
+    org.torproject.onionoo.docs.SummaryDocument relayFerrari458 =
+        new org.torproject.onionoo.docs.SummaryDocument(true, "Ferrari458",
         "001C13B3A55A71B977CA65EC85539D79C653A3FC", Arrays.asList(
         new String[] { "68.38.171.200", "[2001:4f8:3:2e::51]" }),
         DateTimeHelper.parse("2013-04-24 12:00:00"), true,
@@ -124,8 +130,8 @@ public class ResourceServletTest {
         DateTimeHelper.parse("2013-02-12 16:00:00"), "AS7922", null,
         new TreeSet<String>(Arrays.asList(new String[] {
         "000C5F55BD4814B917CC474BD537F1A3B33CCE2A" })));
-    org.torproject.onionoo.SummaryDocument relayTimMayTribute =
-        new org.torproject.onionoo.SummaryDocument(true, "TimMayTribute",
+    org.torproject.onionoo.docs.SummaryDocument relayTimMayTribute =
+        new org.torproject.onionoo.docs.SummaryDocument(true, "TimMayTribute",
         "0025C136C1F3A9EEFE2AE3F918F03BFA21B5070B", Arrays.asList(
         new String[] { "89.69.68.246" }),
         DateTimeHelper.parse("2013-04-22 20:00:00"), false,
@@ -135,24 +141,24 @@ public class ResourceServletTest {
         "1024D/51E2A1C7 steven j. murdoch "
         + "<tor+steven.murdoch@xxxxxxxxxxxx> <fb-token:5sr_k_zs2wm=>",
         new TreeSet<String>());
-    org.torproject.onionoo.SummaryDocument bridgeec2bridgercc7f31fe =
-        new org.torproject.onionoo.SummaryDocument(false,
+    org.torproject.onionoo.docs.SummaryDocument bridgeec2bridgercc7f31fe =
+        new org.torproject.onionoo.docs.SummaryDocument(false,
         "ec2bridgercc7f31fe", "0000831B236DFF73D409AD17B40E2A728A53994F",
         Arrays.asList(new String[] { "10.199.7.176" }),
         DateTimeHelper.parse("2013-04-21 18:07:03"), false,
         new TreeSet<String>(Arrays.asList(new String[] { "Valid" })), -1L,
         null, DateTimeHelper.parse("2013-04-20 15:37:04"), null, null,
         null);
-    org.torproject.onionoo.SummaryDocument bridgeUnnamed =
-        new org.torproject.onionoo.SummaryDocument(false, "Unnamed",
+    org.torproject.onionoo.docs.SummaryDocument bridgeUnnamed =
+        new org.torproject.onionoo.docs.SummaryDocument(false, "Unnamed",
         "0002D9BDBBC230BD9C78FF502A16E0033EF87E0C", Arrays.asList(
         new String[] { "10.0.52.84" }),
         DateTimeHelper.parse("2013-04-20 17:37:04"), false,
         new TreeSet<String>(Arrays.asList(new String[] { "Valid" })), -1L,
         null, DateTimeHelper.parse("2013-04-14 07:07:05"), null, null,
         null);
-    org.torproject.onionoo.SummaryDocument bridgegummy =
-        new org.torproject.onionoo.SummaryDocument(false, "gummy",
+    org.torproject.onionoo.docs.SummaryDocument bridgegummy =
+        new org.torproject.onionoo.docs.SummaryDocument(false, "gummy",
         "1FEDE50ED8DBA1DD9F9165F78C8131E4A44AB756", Arrays.asList(
         new String[] { "10.63.169.98" }),
         DateTimeHelper.parse("2013-04-24 01:07:04"), true,
@@ -160,7 +166,7 @@ public class ResourceServletTest {
         "Valid" })), -1L, null,
         DateTimeHelper.parse("2013-01-16 21:07:04"), null, null, null);
     this.relays =
-        new TreeMap<String, org.torproject.onionoo.SummaryDocument>();
+        new TreeMap<String, org.torproject.onionoo.docs.SummaryDocument>();
     this.relays.put("000C5F55BD4814B917CC474BD537F1A3B33CCE2A",
         relayTorkaZ);
     this.relays.put("001C13B3A55A71B977CA65EC85539D79C653A3FC",
@@ -168,7 +174,7 @@ public class ResourceServletTest {
     this.relays.put("0025C136C1F3A9EEFE2AE3F918F03BFA21B5070B",
         relayTimMayTribute);
     this.bridges =
-        new TreeMap<String, org.torproject.onionoo.SummaryDocument>();
+        new TreeMap<String, org.torproject.onionoo.docs.SummaryDocument>();
     this.bridges.put("0000831B236DFF73D409AD17B40E2A728A53994F",
         bridgeec2bridgercc7f31fe);
     this.bridges.put("0002D9BDBBC230BD9C78FF502A16E0033EF87E0C",
@@ -201,11 +207,11 @@ public class ResourceServletTest {
     updateStatus.setDocumentString(String.valueOf(
         this.currentTimeMillis));
     documentStore.addDocument(updateStatus, null);
-    for (Map.Entry<String, org.torproject.onionoo.SummaryDocument> e :
+    for (Map.Entry<String, org.torproject.onionoo.docs.SummaryDocument> e :
         this.relays.entrySet()) {
       documentStore.addDocument(e.getValue(), e.getKey());
     }
-    for (Map.Entry<String, org.torproject.onionoo.SummaryDocument> e :
+    for (Map.Entry<String, org.torproject.onionoo.docs.SummaryDocument> e :
         this.bridges.entrySet()) {
       documentStore.addDocument(e.getValue(), e.getKey());
     }
diff --git a/test/org/torproject/onionoo/UptimeDocumentWriterTest.java b/test/org/torproject/onionoo/UptimeDocumentWriterTest.java
index 5065e4d..5a77514 100644
--- a/test/org/torproject/onionoo/UptimeDocumentWriterTest.java
+++ b/test/org/torproject/onionoo/UptimeDocumentWriterTest.java
@@ -10,6 +10,13 @@ import java.util.List;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.torproject.onionoo.docs.GraphHistory;
+import org.torproject.onionoo.docs.UptimeDocument;
+import org.torproject.onionoo.docs.UptimeStatus;
+import org.torproject.onionoo.updater.DescriptorType;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
+import org.torproject.onionoo.writer.UptimeDocumentWriter;
 
 public class UptimeDocumentWriterTest {
 
diff --git a/test/org/torproject/onionoo/UptimeStatusTest.java b/test/org/torproject/onionoo/UptimeStatusTest.java
index 884ccc5..671ffa3 100644
--- a/test/org/torproject/onionoo/UptimeStatusTest.java
+++ b/test/org/torproject/onionoo/UptimeStatusTest.java
@@ -9,6 +9,10 @@ import java.util.TreeSet;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.torproject.onionoo.docs.UptimeHistory;
+import org.torproject.onionoo.docs.UptimeStatus;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
 
 public class UptimeStatusTest {
 
diff --git a/test/org/torproject/onionoo/UptimeStatusUpdaterTest.java b/test/org/torproject/onionoo/UptimeStatusUpdaterTest.java
index a34292b..8070ae4 100644
--- a/test/org/torproject/onionoo/UptimeStatusUpdaterTest.java
+++ b/test/org/torproject/onionoo/UptimeStatusUpdaterTest.java
@@ -6,6 +6,12 @@ import static org.junit.Assert.assertEquals;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.torproject.onionoo.docs.UptimeHistory;
+import org.torproject.onionoo.docs.UptimeStatus;
+import org.torproject.onionoo.updater.DescriptorType;
+import org.torproject.onionoo.updater.UptimeStatusUpdater;
+import org.torproject.onionoo.util.ApplicationFactory;
+import org.torproject.onionoo.util.DateTimeHelper;
 
 public class UptimeStatusUpdaterTest {
 



_______________________________________________
tor-commits mailing list
tor-commits@xxxxxxxxxxxxxxxxxxxx
https://lists.torproject.org/cgi-bin/mailman/listinfo/tor-commits