site

(djk)
2011-02-26: Worked around FCP GetCHKOnly bug. Other fixes. Has some local

Worked around FCP GetCHKOnly bug. Other fixes. Has some local wormarc changes. Go back over this changeset.

diff --git a/alien/src/wormarc/IOUtil.java b/alien/src/wormarc/IOUtil.java
--- a/alien/src/wormarc/IOUtil.java
+++ b/alien/src/wormarc/IOUtil.java
@@ -37,6 +37,7 @@ import java.io.OutputStream;
 
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
+import java.security.DigestInputStream;
 
 import java.util.Random;
 
@@ -83,27 +84,38 @@ public class IOUtil {
         }
     }
 
+    public final static DigestInputStream getSha1DigestInputStream(InputStream fromStream)
+        throws IOException {
+        try {
+            MessageDigest sha1 = MessageDigest.getInstance("SHA");
+            return new DigestInputStream(fromStream, sha1);
+        }
+        catch (NoSuchAlgorithmException nsae) {
+            fromStream.close();
+            throw new IOException("Couldn't load SHA1 algorithm.");
+        }
+    }
+
+    private static class NullOutputStream extends OutputStream {
+        public void write(int b) throws IOException {}
+        public void write(byte[] b,
+                          int off,
+                          int len)
+            throws IOException {
+            if (b == null) throw new NullPointerException();
+            if (off < 0 || len < 0 || off + len > b.length) {
+                throw new IndexOutOfBoundsException();
+            }
+        }
+    }
+
+    // Hmmm... old code was probably faster.
     // Closes stream.
     public final static LinkDigest getFileDigest(InputStream fromStream) throws IOException {
-        MessageDigest sha1 = null;
+        DigestInputStream inputStream = getSha1DigestInputStream(fromStream);
         try {
-            try {
-                sha1 = MessageDigest.getInstance("SHA");
-            }
-            catch (NoSuchAlgorithmException nsae) {
-                throw new IOException("Couldn't load SHA1 algorithm.");
-            }
-
-            // DCI: Better to use a wrapper stream filter from the java crypto lib?
-            byte[] buffer = new byte[BUF_LEN];
-            while (true) {
-                int bytesRead = fromStream.read(buffer);
-                if (bytesRead == -1) {
-                    break;
-                }
-                sha1.update(buffer, 0, bytesRead);
-            }
-            return new LinkDigest(sha1.digest());
+            copyAndClose(inputStream, new NullOutputStream());
+            return new LinkDigest(inputStream.getMessageDigest().digest());
         }
         finally {
             fromStream.close();
diff --git a/alien/src/wormarc/io/FCPCommandRunner.java b/alien/src/wormarc/io/FCPCommandRunner.java
--- a/alien/src/wormarc/io/FCPCommandRunner.java
+++ b/alien/src/wormarc/io/FCPCommandRunner.java
@@ -29,6 +29,8 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.PrintStream;
 
+import java.security.DigestInputStream;
+
 import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
@@ -54,12 +56,13 @@ import net.pterodactylus.fcp.FcpMessage;
 import wormarc.Block;
 import wormarc.HistoryLinkMap;
 import wormarc.IOUtil;
+import wormarc.LinkDigest;
 
 // Christ on a bike! is jfcplib really this complicated?
 public class FCPCommandRunner {
     private final static Verbosity VERBOSITY = Verbosity.ALL;
     private final static Priority PRIORITY = Priority.interactive;
-    private final static int MAX_RETRIES = 3;
+    private final static int MAX_RETRIES = 6;
     private final static boolean DONT_COMPRESS = true;
     private final static String REAL_TIME_FIELD = "RealTimeFlag";
     private final static String REAL_TIME_VALUE = "true";
@@ -170,7 +173,9 @@ public class FCPCommandRunner {
             }
 
             // DCI: Handle too big! (code == 21). It means the
-            handleDone(String.format("ClientGet failed: %d", getFailed.getCode()));
+            handleDone(String.format("ClientGet failed: [%d]: %s",
+                                     getFailed.getCode(),
+                                     getFailed.getShortCodeDescription()));
         }
 
 	public void receivedPutFailed(FcpConnection fcpConnection, PutFailed putFailed) {
@@ -178,7 +183,9 @@ public class FCPCommandRunner {
                 return;
             }
 
-            handleDone(String.format("ClientPut failed: %d", putFailed.getCode()));
+            handleDone(String.format("ClientPut failed: [%d]: %s",
+                                     putFailed.getCode(),
+                                     putFailed.getShortCodeDescription()));
         }
 
         public void receivedAllData(FcpConnection fcpConnection, AllData allData) {
@@ -248,6 +255,7 @@ public class FCPCommandRunner {
     static class GetBlock extends Command {
         private long mLength;
         private Block mBlock;
+        private String mHexDigest;
         private FreenetIO mIO;
 
         protected GetBlock(String name, String uri, long length, FreenetIO io, FCPCommandRunner runner) {
@@ -265,7 +273,11 @@ public class FCPCommandRunner {
             if (data == null) {
                 throw new IllegalArgumentException("data == null");
             }
-            mBlock = mIO.readLinks(data);
+
+            DigestInputStream digestInput = IOUtil.getSha1DigestInputStream(data);
+            mBlock = mIO.readLinks(digestInput);
+            LinkDigest digest = new LinkDigest(digestInput.getMessageDigest().digest());
+            mHexDigest = digest.toString();
         }
 
         protected FcpMessage getStartMessage() {
@@ -279,21 +291,32 @@ public class FCPCommandRunner {
         }
 
         public Block getBlock() { return mBlock; }
+        public String getHexDigest() { return mHexDigest; }
     }
 
     static class PutBlock extends Command { // DCI: sleazy. How does stream get closed in failure cases?
         private long mLength;
-        private InputStream mData;
-        public PutBlock(String name, long length, InputStream data, FCPCommandRunner runner) {
+        private DigestInputStream mData;
+        private String mHexDigest;
+
+        public PutBlock(String name, long length, InputStream data, FCPCommandRunner runner) throws IOException {
             super(name, "CHK@", runner);
             mLength = length;
-            mData = data;
+            mData = IOUtil.getSha1DigestInputStream(data);
         }
 
         protected void handleData(long length, InputStream data) throws IOException {
             handleDone("Not expecting AllData");
         }
 
+	public void receivedPutSuccessful(FcpConnection fcpConnection, PutSuccessful putSuccessful) {
+            LinkDigest digest = new LinkDigest(mData.getMessageDigest().digest());
+            mHexDigest = digest.toString();
+
+            // Order important. This posts handleDone.
+            super.receivedPutSuccessful(fcpConnection, putSuccessful);
+        }
+
         protected FcpMessage getStartMessage() {
             ClientPut msg = new ClientPut(mUri, mFcpId);
             msg.setDataLength(mLength);
@@ -305,6 +328,8 @@ public class FCPCommandRunner {
             msg.setField(REAL_TIME_FIELD, REAL_TIME_VALUE);
             return msg;
         }
+        public long getLength() { return mLength; }
+        public String getHexDigest() { return mHexDigest; }
     }
 
     static class GetBlockChk extends Command { // DCI: sleazy. How does stream get closed in failure cases?
@@ -330,6 +355,8 @@ public class FCPCommandRunner {
             msg.setDontCompress(DONT_COMPRESS);
             msg.setPriority(PRIORITY);
             msg.setGetCHKOnly(true);
+            msg.setMaxRetries(MAX_RETRIES);
+            msg.setField(REAL_TIME_FIELD, REAL_TIME_VALUE);
             return msg;
         }
     }
@@ -428,6 +455,8 @@ public class FCPCommandRunner {
             msg.setDontCompress(DONT_COMPRESS);
             msg.setPriority(PRIORITY);
             msg.setGetCHKOnly(true); // Also works for SSKs.
+            msg.setField(REAL_TIME_FIELD, REAL_TIME_VALUE);
+            msg.setMaxRetries(MAX_RETRIES);
             return msg;
         }
     }
diff --git a/alien/src/wormarc/io/FreenetIO.java b/alien/src/wormarc/io/FreenetIO.java
--- a/alien/src/wormarc/io/FreenetIO.java
+++ b/alien/src/wormarc/io/FreenetIO.java
@@ -30,8 +30,10 @@ import java.io.PrintStream;
 
 import java.util.Arrays;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 import wormarc.Archive;
@@ -48,6 +50,7 @@ import wormarc.RootObjectKind;
 
 public class FreenetIO implements Archive.IO, ArchiveResolver {
     private LinkCache mCache;
+    private Map<String, String> mSha1ToChk;
 
     // Transient
     private HistoryLinkMap mLinkMap;
@@ -62,16 +65,31 @@ public class FreenetIO implements Archiv
 
     private String mInsertUri;
     private String mRequestUri;
+
+    // DCI: REMOVE THIS, NO LONGER USED?
     private FreenetTopKey mPreviousTopKey;
 
     private static PrintStream sDebugOut = System.err;
 
+    protected void debug(String msg) {
+        synchronized(sDebugOut) {
+            sDebugOut.println(msg);
+        }
+    }
+
     // Cache can be null.
     // When it is non-null all links read from Freenet are dumped to the cache.
-    public FreenetIO(String host, int port, LinkCache cache) {
+    // sha1ToChk can be null.
+    // When it is non-null entries are added to the table for all blocks that are read or written.
+    public FreenetIO(String host, int port, LinkCache cache, Map<String, String> sha1ToChk) {
         mHost = host;
         mPort = port;
         mCache = cache;
+        mSha1ToChk = sha1ToChk;
+    }
+
+    public FreenetIO(String host, int port, LinkCache cache) {
+        this(host, port, cache, null);
     }
 
     public FreenetIO(String host, int port) {
@@ -171,38 +189,46 @@ public class FreenetIO implements Archiv
                                           mClientName +
                                           IOUtil.randomHexString(12));
 
-            // Precompute the block CHKs so we can skip blocks that
-            // are already in Freenet.
+            // Contains full descriptions for blocks that are known
+            // to exist in Freenet.
             List<FreenetTopKey.BlockDescription> descriptions =
-                precomputeDescriptions(runner, linkMap, blocks);
+                precomputeDescriptions(linkMap, blocks);
 
             if (blocks.size() != descriptions.size()) {
                 throw new RuntimeException("Assertion Failure: blocks.size() != descriptions.size()");
             }
 
-            Set<String> previousChks =  new HashSet<String>();
-            if (mPreviousTopKey != null) {
-                for (FreenetTopKey.BlockDescription desc : mPreviousTopKey.mBlockDescriptions) {
-                    for (int subIndex = 0; subIndex < desc.mCHKs.size(); subIndex++) {
-                        previousChks.add(desc.getCHK(subIndex));
-                    }
-                }
-            }
-
             List<FCPCommandRunner.PutBlock> puts = new ArrayList<FCPCommandRunner.PutBlock>();
             for (int index = 0; index < descriptions.size(); index++) {
                 FreenetTopKey.BlockDescription desc = descriptions.get(index);
-                if (previousChks.contains(desc.getCHK(0))) {
-                    // i.e. the block was already inserted, so skip it.
+                if (desc != null) {
+                    // i.e. the block was already inserted, so skip it, but add a place holder.
+                    puts.add(null);
                     continue;
                 }
+                // Need to insert the block.
                 puts.add(runner.sendPutBlock(index, linkMap, blocks.get(index)));
             }
 
             runner.waitUntilAllFinished();
 
+            int pos = 0;
             for (FCPCommandRunner.PutBlock put : puts) {
-                put.raiseOnFailure();
+                if (put != null) {
+                    put.raiseOnFailure();
+                    List<String> chks = Arrays.asList(put.getUri());
+                    descriptions.set(pos,
+                                     FreenetTopKey.makeDescription(put.getLength(),
+                                                                   chks));
+                }
+                pos++;
+            }
+
+            // Hmmm... really should only update the block sha1 -> CHK cache after full success.
+            for (FCPCommandRunner.PutBlock put : puts) {
+                if (put != null) {
+                    cacheBlockChk(put.getHexDigest(), put.getUri());
+                }
             }
 
             FreenetTopKey topKey = new FreenetTopKey(rootObjects, descriptions);
@@ -214,6 +240,7 @@ public class FreenetIO implements Archiv
             runner.waitUntilAllFinished();
             putTopKey.raiseOnFailure();
             mRequestUri = putTopKey.getUri();
+
         } catch (InterruptedException ie) {
             throw new IOException("Write timed out.", ie);
         } catch (IllegalBase64Exception ibe) {
@@ -281,9 +308,9 @@ public class FreenetIO implements Archiv
             List<FCPCommandRunner.GetBlock> gets = new ArrayList<FCPCommandRunner.GetBlock>();
             for (FreenetTopKey.BlockDescription desc : topKey.mBlockDescriptions ) {
                 // LATER: Handle redundant block fetches.
-                sDebugOut.println(String.format("Requesting[%d]: %s",
-                                                desc.mLength,
-                                                desc.getCHK(0)));
+                debug(String.format("Requesting[%d]: %s",
+                                    desc.mLength,
+                                    desc.getCHK(0)));
                 gets.add(runner.sendGetBlock(desc.getCHK(0), desc.mLength, count++, this));
             }
             runner.waitUntilAllFinished();
@@ -293,9 +320,12 @@ public class FreenetIO implements Archiv
             for (FCPCommandRunner.GetBlock get : gets) {
                 get.raiseOnFailure();
                 blocks.add(get.getBlock());
+
+                // Save, so that we know we don't need to insert this block
+                // when inserting the update.
+                cacheBlockChk(get.getHexDigest(), get.getUri());
             }
             return new Archive.ArchiveData(blocks, topKey.mRootObjects);
-
         } catch (InterruptedException ie) {
             throw new IOException("Read timed out.", ie);
         } catch (IllegalBase64Exception ibe) {
@@ -304,7 +334,7 @@ public class FreenetIO implements Archiv
             mLinkMap = null;
             mLinkDataFactory = null;
             if (runner != null) {
-                sDebugOut.println("FCP Connection -- DISCONNECTING!");
+                debug("FCP Connection -- DISCONNECTING!");
                 runner.disconnect();
             }
         }
@@ -332,29 +362,40 @@ public class FreenetIO implements Archiv
     }
 
     ////////////////////////////////////////////////////////////
-    private List<FreenetTopKey.BlockDescription> precomputeDescriptions(FCPCommandRunner runner,
-                                                                        HistoryLinkMap linkMap,
+    private void cacheBlockChk(String hexDigest, String chk) {
+        synchronized(mSha1ToChk) {
+            debug(String.format("cached: %s -> %s", hexDigest, chk));
+            mSha1ToChk.put(hexDigest, chk);
+        }
+    }
+
+    private String getCachedChk(String hexDigest) {
+        synchronized(mSha1ToChk) {
+            if (hexDigest == null) {
+                return null;
+            }
+            return mSha1ToChk.get(hexDigest);
+        }
+    }
+
+    private List<FreenetTopKey.BlockDescription> precomputeDescriptions(HistoryLinkMap linkMap,
                                                                         List<Block> blocks)
         throws IllegalBase64Exception,
                InterruptedException,
                IOException {
 
-        // Use the Freenet node to tell us the CHKs for the new blocks without
-        // inserting them.
-        int count = 0;
-        List<FCPCommandRunner.GetBlockChk> getChks = new ArrayList<FCPCommandRunner.GetBlockChk>();
+        List<FreenetTopKey.BlockDescription> descriptions = new ArrayList<FreenetTopKey.BlockDescription>();
         for (Block block : blocks) {
-            getChks.add(runner.sendGetBlockChk(count++, linkMap, block));
-        }
-
-        runner.waitUntilAllFinished();
-
-        int index = 0;
-        List<FreenetTopKey.BlockDescription> descriptions = new ArrayList<FreenetTopKey.BlockDescription>();
-        for (FCPCommandRunner.GetBlockChk get : getChks) {
-            get.raiseOnFailure();
-            descriptions.add(FreenetTopKey.makeDescription(get.getLength(), Arrays.asList(get.getUri())));
-            index++;
+            String hexDigest = IOUtil.getFileDigest(linkMap.getBinaryRep(block)).toString();
+            String chk = getCachedChk(hexDigest);
+            if (chk != null) {
+                // Don't need to insert.
+                long length = linkMap.getLength(block); // LATER: do better. Shouldn't be making multiple passes.
+                descriptions.add(FreenetTopKey.makeDescription(length, Arrays.asList(chk)));
+                continue;
+            }
+            // Do need to insert. Add placeholder.
+            descriptions.add(null);
         }
         return descriptions;
     }
@@ -367,7 +408,7 @@ public class FreenetIO implements Archiv
             if (fromReference.mKind != ExternalRefs.KIND_FREENET) {
                 throw new IOException("Reference is not a Freenet URI");
             }
-            sDebugOut.println("resolving Archive from: " + fromReference.mExternalKey);
+            debug("resolving Archive from: " + fromReference.mExternalKey);
             mRequestUri = fromReference.mExternalKey;
             Archive loaded = Archive.load(this); // Hmmmm... slurps stuff into the cache. ???
             if (!loaded.getRootObject(RootObjectKind.ARCHIVE_MANIFEST).isNullDigest()) {
diff --git a/doc/quickstart.txt b/doc/quickstart.txt
--- a/doc/quickstart.txt
+++ b/doc/quickstart.txt
@@ -11,14 +11,24 @@
 # Adjust any other values as necessary.  If you're running FMS and Fred on the same machine on the default ports this shouldn't be necessary.
 # Click the "Done" button to save the configuration changes.
 
-=== Finding Other Versions===
+=== Finding Other Versions Of the testwiki===
 Click the "Discover" link below to search for other versions of the wiki.
 
+If you don't see anything read the  "FMS configuration" section below.
+
 === Submitting ===
 Use the "Submit" link below to submit your changes.  It may take a long time for other people to see them.
 
+=== There are other wikis! ===
+With the default settings you load the "testwiki" I have been using for testing.
+
+There are other wikis out there.
+
+Try entering "freenetdocwiki" in for the "Wiki Name" on the configuration page, then doing "Discover".
+
 === Known Limitations ===
 # There is no merging or rebasing (yet).
+# The only way to start an empty wiki from scratch is by loading and unloading the plugin (or running stand alone from the command line).
 
 ----
 === Finding Your Private SSK ===
@@ -44,3 +54,16 @@ SSK@YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY
    <PublishFreesite>false</PublishFreesite>
 </Identity>
 }}}
+
+----
+===FMS Configuration===
+
+By default the prototype uses the "biss.test000" FMS group.
+Make sure FMS is configured to save messages to this group.
+
+On the FMS Boards maintenance page:
+http://127.0.0.1:18080/boards.htm
+
+Search for "biss.test000" and make sure "Save Received Messages" is checked.
+
+If you can't find the group, add it.
diff --git a/readme.txt b/readme.txt
--- a/readme.txt
+++ b/readme.txt
@@ -1,4 +1,4 @@
-201102020
+20110227
 djk@isFiaD04zgAgnrEC5XJt1i4IE7AkNPqhBG5bONi6Yks
 
 WARNING:
@@ -73,3 +73,15 @@ KNOWN ISSUES:
 o "Cancel" sometimes fails. [WORKAROUND: load and unload the plugin / kill restart the stand alone app.]
 o FMS Id displays "???" when importing config with non-default FCP host and/or port.
   [WORKAROUND: Click "Done", then click view again and the FMS Id should be correctly displayed.]
+
+---
+Dev notes
+---
+IDEA: shrink blocks by using a token map?  use short token in binary rep, fixup to full 20byte hash on read / write?
+IDEA: Support links to other wikis. e.g.: fniki://fms/group/name
+IDEA: Why isn't this file in Creole?
+
+BUG: wikitext should use unix line terminators not DOS (+1 byte per line)
+BUG: No way to create an empty wiki from the UI. [requested by a real user]
+BUG: Default FCP port wrong for CLI client. [requested by a real user]
+
diff --git a/src/fniki/standalone/FnikiContextHandler.java b/src/fniki/standalone/FnikiContextHandler.java
--- a/src/fniki/standalone/FnikiContextHandler.java
+++ b/src/fniki/standalone/FnikiContextHandler.java
@@ -56,14 +56,14 @@ public class FnikiContextHandler impleme
         private final String readAsUtf8(HTTPServer.MultipartIterator.Part part) throws IOException {
             ByteArrayOutputStream baos = new ByteArrayOutputStream();
 
-            while (part.body.available() > 0) { // Do better? Does it matter?
+            while (true) { // Do better? Does it matter?
                 int oneByte = part.body.read();
                 if (oneByte == -1) {
-                    throw new IOException("Unexpected EOF???");
+                    break;
                 }
                 baos.write(oneByte);
             }
-
+            System.err.println("read part bytes: " +  baos.toByteArray().length);
             return new String(baos.toByteArray(), "utf8");
         }
 
@@ -90,8 +90,10 @@ public class FnikiContextHandler impleme
                         continue;
                     }
                     mParamTable.put(part.name, readAsUtf8(part));
-                    System.err.println("Set multipart Param: " + part.name + " : " +
-                                       mParamTable.get(part.name));
+                    System.err.println(String.format("Set multipart Param: %s[%d]:\n%s",
+                                                     part.name,
+                                                     mParamTable.get(part.name).length(),
+                                                     mParamTable.get(part.name)));
                 }
                 mParent.consumeBody();
             }
diff --git a/src/fniki/wiki/ArchiveManager.java b/src/fniki/wiki/ArchiveManager.java
--- a/src/fniki/wiki/ArchiveManager.java
+++ b/src/fniki/wiki/ArchiveManager.java
@@ -66,6 +66,12 @@ public class ArchiveManager {
     // Base64 SSK public key hash to FMS name. i.e. the part before '@'.
     Map<String, String> mNymLut = new HashMap<String, String>();
 
+    // LATER: Revisit. This was HACK to work around the fact that GetCHKOnly=true
+    //        is broken for splitfiles.
+    //
+    // Block hex digest to CHK key map.
+    Map<String, String> mSha1ToChk = new HashMap<String, String>();
+
     String mParentUri;
     Archive mArchive;
     FileManifest mFileManifest;
@@ -89,7 +95,7 @@ public class ArchiveManager {
 
     public String invertPrivateSSK(String value, int timeoutMs) throws IOException {
         validatePrivateSSK(value);
-        return (new FreenetIO(mFcpHost, mFcpPort)).invertPrivateSSK(value, timeoutMs);
+        return makeIO().invertPrivateSSK(value, timeoutMs);
     }
 
     public String getPrivateSSK() { return mPrivateSSK; }
@@ -125,7 +131,7 @@ public class ArchiveManager {
 
     // DCI: Fix this to roll back state on exceptions.
     public void load(String uri) throws IOException {
-        FreenetIO io = new FreenetIO(mFcpHost, mFcpPort);
+        FreenetIO io = makeIO();
         io.setRequestUri(uri);
         Archive archive = Archive.load(io);
         validateUriHashes(archive, uri, true);
@@ -149,6 +155,10 @@ public class ArchiveManager {
         }
     }
 
+    private FreenetIO makeIO() {
+        return new FreenetIO(mFcpHost, mFcpPort, null, mSha1ToChk);
+    }
+
     // The name of a jfniki archive includes the hash of the
     // full archive manifest file, and hashes of the SSK of
     // it's parent(s).
@@ -259,10 +269,8 @@ public class ArchiveManager {
         out.println("Insert URI: " + insertUri);
 
         // Push the updated version into Freenet.
-        FreenetIO io = new FreenetIO(mFcpHost, mFcpPort);
+        FreenetIO io = makeIO();
         io.setInsertUri(insertUri);
-        out.println("Trying to read previous top key if possible...");
-        io.maybeLoadPreviousTopKey(copy);
         out.println("Writing to Freenet...");
         copy.write(io);
 
@@ -300,7 +308,7 @@ public class ArchiveManager {
         ExternalRefs.Reference head =
             new ExternalRefs.Reference(ExternalRefs.KIND_FREENET, mParentUri);
 
-        FreenetIO freenetResolver = new FreenetIO(mFcpHost, mFcpPort);
+        FreenetIO freenetResolver = makeIO();
         Archive archive = freenetResolver.resolve(head);
         AuditArchive.getManifestChangeLog(head, archive, freenetResolver, callback);
     }
diff --git a/src/fniki/wiki/child/WikiContainer.java b/src/fniki/wiki/child/WikiContainer.java
--- a/src/fniki/wiki/child/WikiContainer.java
+++ b/src/fniki/wiki/child/WikiContainer.java
@@ -43,6 +43,8 @@ import fniki.wiki.ChildContainerExceptio
 import fniki.wiki.Query;
 
 import wormarc.FileManifest;
+import wormarc.IOUtil;
+
 import fniki.wiki.FreenetWikiTextParser;
 import static fniki.wiki.HtmlUtils.*;
 import static fniki.wiki.Validations.*;
@@ -226,50 +228,34 @@ public class WikiContainer implements Ch
     }
 
     private String getEditorHtml(WikiContext context, String name) throws IOException {
-        StringBuilder buffer = new StringBuilder();
-        addHeader(name, buffer);
+        String template = null;
+        try {
+            // IMPORTANT: Only multipart/form-data encoding works in plugins.
+            // IMPORTANT: Must be multipart/form-data even for standalone because
+            //            the Freenet ContentFilter rewrites the encoding in all forms
+            //            to this value.
+            template = IOUtil.readUtf8StringAndClose(SettingConfig.class.getResourceAsStream("/edit_form.html"));
+        } catch (IOException ioe) {
+            return "Couldn't load edit_form.html template from jar???";
+        }
 
+        String escapedName = escapeHTML(unescapedTitleFromName(name));
         String href = makeHref(context.makeLink("/" +name),
                                "save", null, null, null);
-
-
-        buffer.append("<form method=\"post\" action=\"" +
-                      href +
-                      "\" enctype=\"");
-
-        // IMPORTANT: Only multipart/form-data encoding works in plugins.
-        // IMPORTANT: Must be multipart/form-date even for standalone because
-        //            the Freenet ContentFilter rewrites the encoding in all forms
-        //            to this value.
-        buffer.append("multipart/form-data");
-        buffer.append("\" accept-charset=\"UTF-8\">\n");
-
-        buffer.append("<input type=hidden name=\"savepage\" value=\"");
-        buffer.append(escapeHTML(name));
-        buffer.append("\">\n");
-
-        buffer.append("<textarea wrap=\"virtual\" name=\"savetext\" rows=\"17\" cols=\"120\">\n");
-
+        String wikiText = "Page doesn't exist in the wiki yet.";
         if (context.getStorage().hasPage(name)) {
-            buffer.append(escapeHTML(context.getStorage().getPage(name)));
-        } else {
-            buffer.append(escapeHTML("Page doesn't exist in the wiki yet."));
+            wikiText = context.getStorage().getPage(name);
         }
 
-        buffer.append("</textarea>\n");
-        buffer.append("<br><input type=submit value=\"Save\">\n");
-        buffer.append("<input type=hidden name=formPassword value=\"");
-
-        // IMPORTANT: Required by Freenet Plugin.
-        buffer.append(context.getString("form_password", "FORM_PASSWORD_NOT_SET"));  // Doesn't need escaping.
-        buffer.append("\"/>\n");
-        buffer.append("<input type=reset value=\"Reset\">\n");
-        buffer.append("<br></form>");
-
-        buffer.append("<hr>\n");
-        buffer.append("</body></html>\n");
-
-        return buffer.toString();
+        return String.format(template,
+                             escapedName,
+                             escapedName,
+                             href,
+                             escapeHTML(name), // i.e. with '_' chars
+                             escapeHTML(wikiText),
+                             // IMPORTANT: Required by Freenet Plugin.
+                             // Doesn't need escaping.
+                             context.getString("form_password", "FORM_PASSWORD_NOT_SET"));
     }
 
     public String renderXHTML(WikiContext context, String wikiText) {
diff --git a/templates/edit_form.html b/templates/edit_form.html
new file mode 100644
--- /dev/null
+++ b/templates/edit_form.html
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<title>%s</title>
+<style type="text/css">div.indent{margin-left:20px;} div.center{text-align:center;} blockquote{margin-left:20px;background-color:#e0e0e0;} span.underline{text-decoration:underline;}
+</style>
+</head>
+<body>
+<h1>%s</h1>
+<hr/>
+<form method="post"
+      action="%s"
+      enctype="multipart/form-data"
+      accept-charset="UTF-8">
+  <input type=hidden name="savepage" value="%s"/>
+  <textarea wrap="virtual" name="savetext" rows="17" cols="120">%s</textarea>
+  <br/>
+  <input type=submit value="Save"/>
+  <input type=hidden name=formPassword value="%s"/>
+  <input type=reset value="Reset"/>
+  <br/>
+</form>
+<hr/>
+</body>
+</html>
+