Beispiel #1
0
/**
 * Provides different ways to sort Books.
 *
 * @see gnu.lgpl.License for license details.<br>
 *     The copyright to this program is held by it's authors.
 * @author DM Smith [dmsmith555 at yahoo dot com]
 */
public final class BookComparators {
  /** Ensure we can't be created */
  private BookComparators() {}

  /** Order by default Book ordering */
  public static Comparator<Book> getDefault() {
    return new Comparator<Book>() {
      public int compare(Book o1, Book o2) {
        return o1.compareTo(o2);
      }
    };
  }

  /** Order by Initials. */
  public static Comparator<Book> getInitialComparator() {
    return new Comparator<Book>() {
      public int compare(Book o1, Book o2) {
        return o1.getInitials().compareTo(o2.getInitials());
      }
    };
  }

  /** The log stream */
  static final Logger log = Logger.getLogger(BookComparators.class);
}
Beispiel #2
0
/**
 * A class to convert between strings and objects of a type.
 *
 * @see gnu.lgpl.License for license details.<br>
 *     The copyright to this program is held by it's authors.
 * @author Joe Walker [joe at eireneh dot com]
 */
public class ClassChoice extends AbstractReflectedChoice {
  /*
   * (non-Javadoc)
   *
   * @see org.crosswire.common.config.Choice#getConvertionClass()
   */
  public Class<?> getConversionClass() {
    return Class.class;
  }

  /*
   * (non-Javadoc)
   *
   * @see
   * org.crosswire.common.config.AbstractReflectedChoice#convertToString(java
   * .lang.Object)
   */
  @Override
  public String convertToString(Object orig) {
    if (orig == null) {
      return null;
    }

    return ((Class<?>) orig).getName();
  }

  /*
   * (non-Javadoc)
   *
   * @see
   * org.crosswire.common.config.AbstractReflectedChoice#convertToObject(java
   * .lang.String)
   */
  @Override
  public Object convertToObject(String orig) {
    try {
      return ClassUtil.forName(orig);
    } catch (ClassNotFoundException ex) {
      log.warn("Class not found: " + orig, ex);
      return null;
    }
  }

  /** The log stream */
  private static final Logger log = Logger.getLogger(ClassChoice.class);
}
/**
 * .
 *
 * @see gnu.lgpl.License for license details.<br>
 *     The copyright to this program is held by it's authors.
 * @author Joe Walker [joe at eireneh dot com]
 * @author DM Smith [dmsmith555 at yahoo dot com]
 */
public abstract class AbstractSwordInstaller extends AbstractBookList
    implements Installer, Comparable<AbstractSwordInstaller> {
  /**
   * Utility to download a file from a remote site
   *
   * @param job The way of noting progress
   * @param dir The directory from which to download the file
   * @param file The file to download
   * @throws InstallException
   */
  protected abstract void download(Progress job, String dir, String file, URI dest)
      throws InstallException;

  /*
   * (non-Javadoc)
   *
   * @see org.crosswire.jsword.book.install.Installer#getInstallerDefinition()
   */
  public String getInstallerDefinition() {
    StringBuilder buf = new StringBuilder(host);
    buf.append(',');
    buf.append(packageDirectory);
    buf.append(',');
    buf.append(catalogDirectory);
    buf.append(',');
    buf.append(indexDirectory);
    buf.append(',');
    if (proxyHost != null) {
      buf.append(proxyHost);
    }
    buf.append(',');
    if (proxyPort != null) {
      buf.append(proxyPort);
    }
    return buf.toString();
  }

  /*
   * (non-Javadoc)
   *
   * @see
   * org.crosswire.jsword.book.install.Installer#isNewer(org.crosswire.jsword
   * .book.BookMetaData)
   */
  public boolean isNewer(Book book) {
    File dldir = SwordBookPath.getSwordDownloadDir();

    SwordBookMetaData sbmd = (SwordBookMetaData) book.getBookMetaData();
    File conf = new File(dldir, sbmd.getConfPath());

    // The conf may not exist in our download dir.
    // In this case we say that it should not be downloaded again.
    if (!conf.exists()) {
      return false;
    }

    URI configURI = NetUtil.getURI(conf);

    URI remote = toRemoteURI(book);
    return NetUtil.isNewer(remote, configURI, proxyHost, proxyPort);
  }

  /*
   * (non-Javadoc)
   *
   * @see org.crosswire.jsword.book.BookList#getBooks()
   */
  public List<Book> getBooks() {
    try {
      if (!loaded) {
        loadCachedIndex();
      }

      // We need to create a List from the Set returned by
      // entries.values() so the underlying list is not modified.
      return new ArrayList<Book>(entries.values());
    } catch (InstallException ex) {
      log.error("Failed to reload cached index file", ex);
      return new ArrayList<Book>();
    }
  }

  /*
   * (non-Javadoc)
   *
   * @see org.crosswire.jsword.book.BookList#getBook(java.lang.String)
   */
  public synchronized Book getBook(String name) {
    // Check name first
    // First check for exact matches
    for (Book book : getBooks()) {
      if (name.equals(book.getName())) {
        return book;
      }
    }

    // Next check for case-insensitive matches
    for (Book book : getBooks()) {
      if (name.equalsIgnoreCase(book.getName())) {
        return book;
      }
    }

    // Then check initials
    // First check for exact matches
    for (Book book : getBooks()) {
      BookMetaData bmd = book.getBookMetaData();
      if (name.equals(bmd.getInitials())) {
        return book;
      }
    }

    // Next check for case-insensitive matches
    for (Book book : getBooks()) {
      if (name.equalsIgnoreCase(book.getInitials())) {
        return book;
      }
    }
    return null;
  }

  /*
   * (non-Javadoc)
   *
   * @see
   * org.crosswire.jsword.book.BookList#getBooks(org.crosswire.jsword.book
   * .BookFilter)
   */
  @Override
  public synchronized List<Book> getBooks(BookFilter filter) {
    List<Book> temp = CollectionUtil.createList(new BookFilterIterator(getBooks(), filter));
    return new BookSet(temp);
  }

  /*
   * (non-Javadoc)
   *
   * @see
   * org.crosswire.jsword.book.install.Installer#install(org.crosswire.jsword
   * .book.Book)
   */
  public void install(Book book) {
    // // Is the book already installed? Then nothing to do.
    // if (Books.installed().getBook(book.getName()) != null)
    // {
    // return;
    // }
    //
    final SwordBookMetaData sbmd = (SwordBookMetaData) book.getBookMetaData();

    // So now we know what we want to install - all we need to do
    // is installer.install(name) however we are doing it in the
    // background so we create a job for it.
    final Thread worker =
        new Thread("DisplayPreLoader") {
          /*
           * (non-Javadoc)
           *
           * @see java.lang.Runnable#run()
           */
          @Override
          public void run() {
            // TRANSLATOR: Progress label indicating the installation of a book. {0} is a
            // placeholder for the name of the book.
            String jobName = JSMsg.gettext("Installing book: {0}", sbmd.getName());
            Progress job = JobManager.createJob(jobName, this);

            // Don't bother setting a size, we'll do it later.
            job.beginJob(jobName);

            yield();

            URI temp = null;
            try {
              // TRANSLATOR: Progress label indicating the Initialization of installing of a book.
              job.setSectionName(JSMsg.gettext("Initializing"));

              temp = NetUtil.getTemporaryURI("swd", ZIP_SUFFIX);

              download(job, packageDirectory, sbmd.getInitials() + ZIP_SUFFIX, temp);

              // Once the unzipping is started, we need to continue
              job.setCancelable(false);
              if (!job.isFinished()) {
                File dldir = SwordBookPath.getSwordDownloadDir();
                IOUtil.unpackZip(NetUtil.getAsFile(temp), dldir);
                // TRANSLATOR: Progress label for installing the conf file for a book.
                job.setSectionName(JSMsg.gettext("Copying config file"));
                sbmd.setLibrary(NetUtil.getURI(dldir));
                SwordBookDriver.registerNewBook(sbmd);
              }

            } catch (IOException e) {
              Reporter.informUser(this, e);
              job.cancel();
            } catch (InstallException e) {
              Reporter.informUser(this, e);
              job.cancel();
            } catch (BookException e) {
              Reporter.informUser(this, e);
              job.cancel();
            } finally {
              job.done();
              // tidy up after ourselves
              // This is a best effort. If for some reason it does not delete now
              // it will automatically be deleted when the JVM exits normally.
              if (temp != null) {
                try {
                  NetUtil.delete(temp);
                } catch (IOException e) {
                  log.warn("Error deleting temp download file:" + e.getMessage());
                }
              }
            }
          }
        };

    // this actually starts the thread off
    worker.setPriority(Thread.MIN_PRIORITY);
    worker.start();
  }

  /*
   * (non-Javadoc)
   *
   * @see org.crosswire.jsword.book.install.Installer#reloadIndex()
   */
  public void reloadBookList() throws InstallException {
    // TRANSLATOR: Progress label for downloading one or more files.
    String jobName = JSMsg.gettext("Downloading files");
    Progress job = JobManager.createJob(jobName, Thread.currentThread());
    job.beginJob(jobName);

    try {
      URI scratchfile = getCachedIndexFile();
      download(job, catalogDirectory, FILE_LIST_GZ, scratchfile);
      loaded = false;
    } catch (InstallException ex) {
      job.cancel();
      throw ex;
    } finally {
      job.done();
    }
  }

  /*
   * (non-Javadoc)
   *
   * @see
   * org.crosswire.jsword.book.install.Installer#downloadSearchIndex(org.crosswire
   * .jsword.book.BookMetaData, java.net.URI)
   */
  public void downloadSearchIndex(Book book, URI localDest) throws InstallException {
    // TRANSLATOR: Progress label for downloading one or more files.
    String jobName = JSMsg.gettext("Downloading files");
    Progress job = JobManager.createJob(jobName, Thread.currentThread());
    job.beginJob(jobName);

    // MJD START
    // use and-bible index location
    String indexLocation = "/and-bible/indices/v1";
    try {
      Version versionObj = (Version) book.getBookMetaData().getProperty("Version");
      String version = versionObj == null ? null : versionObj.toString();
      String versionSuffix = version != null ? "-" + version : "";
      download(job, indexLocation, book.getInitials() + versionSuffix + ZIP_SUFFIX, localDest);
      // MJD END
      //          download(job, packageDirectory + '/' + SEARCH_DIR, book.getInitials() +
      // ZIP_SUFFIX, localDest);
    } catch (InstallException ex) {
      job.cancel();
      throw ex;
    } finally {
      job.done();
    }
  }

  /** Load the cached index file into memory */
  private void loadCachedIndex() throws InstallException {
    // We need a sword book driver so the installer can use the driver
    // name to use in deciding where to put the index.
    BookDriver fake = SwordBookDriver.instance();

    entries.clear();

    URI cache = getCachedIndexFile();
    if (!NetUtil.isFile(cache)) {
      reloadBookList();
    }

    InputStream in = null;
    GZIPInputStream gin = null;
    TarInputStream tin = null;
    try {
      ConfigEntry.resetStatistics();

      in = NetUtil.getInputStream(cache);
      gin = new GZIPInputStream(in);
      tin = new TarInputStream(gin);
      while (true) {
        TarEntry entry = tin.getNextEntry();
        if (entry == null) {
          break;
        }

        String internal = entry.getName();
        if (!entry.isDirectory()) {
          try {
            int size = (int) entry.getSize();

            // Every now and then an empty entry sneaks in
            if (size == 0) {
              log.error("Empty entry: " + internal);
              continue;
            }

            byte[] buffer = new byte[size];
            if (tin.read(buffer) != size) {
              // This should not happen, but if it does then skip
              // it.
              log.error("Did not read all that was expected " + internal);
              continue;
            }

            if (internal.endsWith(SwordConstants.EXTENSION_CONF)) {
              internal = internal.substring(0, internal.length() - 5);
            } else {
              log.error("Not a SWORD config file: " + internal);
              continue;
            }

            if (internal.startsWith(SwordConstants.DIR_CONF + '/')) {
              internal = internal.substring(7);
            }

            SwordBookMetaData sbmd = new SwordBookMetaData(buffer, internal);
            sbmd.setDriver(fake);
            Book book = new SwordBook(sbmd, null);
            entries.put(book.getName(), book);
          } catch (IOException ex) {
            log.error("Failed to load config for entry: " + internal, ex);
          }
        }
      }

      loaded = true;

      ConfigEntry.dumpStatistics();
    } catch (IOException ex) {
      throw new InstallException(JSOtherMsg.lookupText("Error loading from cache"), ex);
    } finally {
      IOUtil.close(tin);
      IOUtil.close(gin);
      IOUtil.close(in);
    }
  }

  // MJD START - allow list to be cleared to clear a large block of memory
  @Override
  public void close() {
    entries.clear();
    loaded = false;
  }
  // MJD END

  /** @return the catologDirectory */
  public String getCatalogDirectory() {
    return catalogDirectory;
  }

  /** @param catologDirectory the catologDirectory to set */
  public void setCatalogDirectory(String catologDirectory) {
    this.catalogDirectory = catologDirectory;
  }

  /** @return Returns the directory. */
  public String getPackageDirectory() {
    return packageDirectory;
  }

  /** @param newDirectory The directory to set. */
  public void setPackageDirectory(String newDirectory) {
    if (packageDirectory == null || !packageDirectory.equals(newDirectory)) {
      packageDirectory = newDirectory;
      loaded = false;
    }
  }

  /** @return the indexDirectory */
  public String getIndexDirectory() {
    return indexDirectory;
  }

  /** @param indexDirectory the indexDirectory to set */
  public void setIndexDirectory(String indexDirectory) {
    this.indexDirectory = indexDirectory;
  }

  /** @return Returns the host. */
  public String getHost() {
    return host;
  }

  /** @param newHost The host to set. */
  public void setHost(String newHost) {
    if (host == null || !host.equals(newHost)) {
      host = newHost;
      loaded = false;
    }
  }

  /** @return Returns the proxyHost. */
  public String getProxyHost() {
    return proxyHost;
  }

  /** @param newProxyHost The proxyHost to set. */
  public void setProxyHost(String newProxyHost) {
    String pHost = null;
    if (newProxyHost != null && newProxyHost.length() > 0) {
      pHost = newProxyHost;
    }
    if (proxyHost == null || !proxyHost.equals(pHost)) {
      proxyHost = pHost;
      loaded = false;
    }
  }

  /** @return Returns the proxyPort. */
  public Integer getProxyPort() {
    return proxyPort;
  }

  /** @param newProxyPort The proxyPort to set. */
  public void setProxyPort(Integer newProxyPort) {
    if (proxyPort == null || !proxyPort.equals(newProxyPort)) {
      proxyPort = newProxyPort;
      loaded = false;
    }
  }

  /** The URL for the cached index file for this installer */
  protected URI getCachedIndexFile() throws InstallException {
    try {
      URI scratchdir =
          CWProject.instance()
              .getWriteableProjectSubdir(getTempFileExtension(host, catalogDirectory), true);
      return NetUtil.lengthenURI(scratchdir, FILE_LIST_GZ);
    } catch (IOException ex) {
      throw new InstallException(JSOtherMsg.lookupText("URL manipulation failed"), ex);
    }
  }

  /** What are we using as a temp filename? */
  private static String getTempFileExtension(String host, String catalogDir) {
    return DOWNLOAD_PREFIX + host + catalogDir.replace('/', '_');
  }

  /*
   * (non-Javadoc)
   *
   * @see java.lang.Object#equals(java.lang.Object)
   */
  @Override
  public boolean equals(Object object) {
    if (!(object instanceof AbstractSwordInstaller)) {
      return false;
    }
    AbstractSwordInstaller that = (AbstractSwordInstaller) object;

    if (!equals(this.host, that.host)) {
      return false;
    }

    if (!equals(this.packageDirectory, that.packageDirectory)) {
      return false;
    }

    return true;
  }

  /*
   * (non-Javadoc)
   *
   * @see java.lang.Comparable#compareTo(java.lang.Object)
   */
  public int compareTo(AbstractSwordInstaller myClass) {

    int ret = host.compareTo(myClass.host);
    if (ret != 0) {
      ret = packageDirectory.compareTo(myClass.packageDirectory);
    }
    return ret;
  }

  /*
   * (non-Javadoc)
   *
   * @see java.lang.Object#hashCode()
   */
  @Override
  public int hashCode() {
    return host.hashCode() + packageDirectory.hashCode();
  }

  /** Quick utility to check to see if 2 (potentially null) strings are equal */
  protected boolean equals(String string1, String string2) {
    if (string1 == null) {
      return string2 == null;
    }
    return string1.equals(string2);
  }

  /** A map of the books in this download area */
  protected Map<String, Book> entries = new HashMap<String, Book>();

  /** The remote hostname. */
  protected String host;

  /** The remote proxy hostname. */
  protected String proxyHost;

  /** The remote proxy port. */
  protected Integer proxyPort;

  /** The directory containing zipped books on the <code>host</code>. */
  protected String packageDirectory = "";

  /** The directory containing the catalog of all books on the <code>host</code>. */
  protected String catalogDirectory = "";

  /** The directory containing the catalog of all books on the <code>host</code>. */
  protected String indexDirectory = "";

  /** Do we need to reload the index file */
  protected boolean loaded;

  /** The sword index file */
  protected static final String FILE_LIST_GZ = "mods.d.tar.gz";

  /** The suffix of zip books on this server */
  protected static final String ZIP_SUFFIX = ".zip";

  /** The log stream */
  protected static final Logger log = Logger.getLogger(AbstractSwordInstaller.class);

  /** The relative path of the dir holding the search index files */
  protected static final String SEARCH_DIR = "search/jsword/L1";

  /** When we cache a download index */
  protected static final String DOWNLOAD_PREFIX = "download-";
}
/**
 * A utility class for loading the entries in a Sword book's conf file. Since the conf files are
 * manually maintained, there can be all sorts of errors in them. This class does robust checking
 * and reporting.
 *
 * <p>Config file format. See also: <a href=
 * "http://sword.sourceforge.net/cgi-bin/twiki/view/Swordapi/ConfFileLayout">
 * http://sword.sourceforge.net/cgi-bin/twiki/view/Swordapi/ConfFileLayout</a>
 *
 * <p>The contents of the About field are in rtf.
 *
 * <p>\ is used as a continuation line.
 *
 * @see gnu.lgpl.License for license details.<br>
 *     The copyright to this program is held by it's authors.
 * @author Mark Goodwin [mark at thorubio dot org]
 * @author Joe Walker [joe at eireneh dot com]
 * @author Jacky Cheung
 * @author DM Smith [dmsmith555 at yahoo dot com]
 */
public final class ConfigEntryTable {
  /**
   * Create an empty Sword config for the named book.
   *
   * @param bookName the name of the book
   */
  public ConfigEntryTable(String bookName) {
    table = new HashMap<ConfigEntryType, ConfigEntry>();
    extra = new TreeMap<String, ConfigEntry>();
    internal = bookName;
    supported = true;
  }

  private static long MAX_BUFF_SIZE = 8 * 1024;
  private static int MIN_BUFF_SIZE = 128;

  /**
   * Load the conf from a file.
   *
   * @param file the file to load
   * @throws IOException
   */
  public void load(File file) throws IOException {
    configFile = file;

    BufferedReader in = null;
    try {
      // MJD start get best buffersize but ensure it is not too small (0) nor too large (>default)
      int bufferSize = (int) Math.min(MAX_BUFF_SIZE, file.length());
      bufferSize = Math.max(MIN_BUFF_SIZE, bufferSize);

      // Quiet Android from complaining about using the default BufferReader buffer size.
      // The actual buffer size is undocumented. So this is a good idea any way.
      in =
          new BufferedReader(
              new InputStreamReader(new FileInputStream(file), ENCODING_UTF8), bufferSize);
      // MJD end
      loadInitials(in);
      loadContents(in);
      in.close();
      in = null;
      if (getValue(ConfigEntryType.ENCODING).equals(ENCODING_LATIN1)) {
        supported = true;
        bookType = null;
        questionable = false;
        readahead = null;
        table.clear();
        extra.clear();
        in =
            new BufferedReader(
                new InputStreamReader(new FileInputStream(file), ENCODING_LATIN1), bufferSize);
        loadInitials(in);
        loadContents(in);
        in.close();
        in = null;
      }
      adjustDataPath();
      adjustLanguage();
      adjustBookType();
      adjustName();
      validate();
    } finally {
      if (in != null) {
        in.close();
      }
    }
  }

  /**
   * Load the conf from a buffer. This is used to load conf entries from the mods.d.tar.gz file.
   *
   * @param buffer the buffer to load
   * @throws IOException
   */
  public void load(byte[] buffer) throws IOException {
    BufferedReader in = null;
    try {
      // Quiet Android from complaining about using the default BufferReader buffer size.
      // The actual buffer size is undocumented. So this is a good idea any way.
      in =
          new BufferedReader(
              new InputStreamReader(new ByteArrayInputStream(buffer), ENCODING_UTF8),
              buffer.length);
      loadInitials(in);
      loadContents(in);
      in.close();
      in = null;
      if (getValue(ConfigEntryType.ENCODING).equals(ENCODING_LATIN1)) {
        supported = true;
        bookType = null;
        questionable = false;
        readahead = null;
        table.clear();
        extra.clear();
        in =
            new BufferedReader(
                new InputStreamReader(new ByteArrayInputStream(buffer), ENCODING_LATIN1),
                buffer.length);
        loadInitials(in);
        loadContents(in);
        in.close();
        in = null;
      }
      adjustDataPath();
      adjustLanguage();
      adjustBookType();
      adjustName();
      validate();
    } finally {
      if (in != null) {
        in.close();
      }
    }
  }

  /** Determines whether the Sword Book's conf is supported by JSword. */
  public boolean isQuestionable() {
    return questionable;
  }

  /** Determines whether the Sword Book's conf is supported by JSword. */
  public boolean isSupported() {
    return supported;
  }

  /**
   * Determines whether the Sword Book is enciphered.
   *
   * @return true if enciphered
   */
  public boolean isEnciphered() {
    String cipher = (String) getValue(ConfigEntryType.CIPHER_KEY);
    return cipher != null;
  }

  /**
   * Determines whether the Sword Book is enciphered and without a key.
   *
   * @return true if enciphered
   */
  public boolean isLocked() {
    String cipher = (String) getValue(ConfigEntryType.CIPHER_KEY);
    return cipher != null && cipher.length() == 0;
  }

  /**
   * Unlocks a book with the given key. The key is trimmed of any leading or trailing whitespace.
   *
   * @param unlockKey the key to try
   * @return true if the unlock key worked.
   */
  public boolean unlock(String unlockKey) {
    String tmpKey = unlockKey;
    if (tmpKey != null) {
      tmpKey = tmpKey.trim();
    }
    add(ConfigEntryType.CIPHER_KEY, tmpKey);
    if (configFile != null) {
      try {
        save();
      } catch (IOException e) {
        // TRANSLATOR: Common error condition: The user supplied unlock key could not be saved.
        Reporter.informUser(this, JSMsg.gettext("Unable to save the book's unlock key."));
      }
    }
    return true;
  }

  /**
   * Gets the unlock key for the module.
   *
   * @return the unlock key, if any, null otherwise.
   */
  public String getUnlockKey() {
    return (String) getValue(ConfigEntryType.CIPHER_KEY);
  }

  /** Returns an Enumeration of all the known keys found in the config file. */
  public Set<ConfigEntryType> getKeys() {
    return table.keySet();
  }

  /** Returns an Enumeration of all the unknown keys found in the config file. */
  public Set<String> getExtraKeys() {
    return extra.keySet();
  }

  /** Returns an Enumeration of all the keys found in the config file. */
  public BookType getBookType() {
    return bookType;
  }

  /**
   * Gets a particular ConfigEntry's value by its type
   *
   * @param type of the ConfigEntry
   * @return the requested value, the default (if there is no entry) or null (if there is no
   *     default)
   */
  public Object getValue(ConfigEntryType type) {
    ConfigEntry ce = table.get(type);
    if (ce != null) {
      return ce.getValue();
    }
    return type.getDefault();
  }

  /**
   * Determine whether this ConfigEntryTable has the ConfigEntry and it matches the value.
   *
   * @param type The kind of ConfigEntry to look for
   * @param search the value to match against
   * @return true if there is a matching ConfigEntry matching the value
   */
  // MJD latest change in jsword
  public boolean match(ConfigEntryType type, String search) {
    ConfigEntry ce = table.get(type);
    return ce != null && ce.match(search);
  }

  /** Sort the keys for a more meaningful presentation order. */
  public Element toOSIS() {
    OSISUtil.OSISFactory factory = OSISUtil.factory();
    Element ele = factory.createTable();
    toOSIS(factory, ele, "BasicInfo", BASIC_INFO);
    toOSIS(factory, ele, "LangInfo", LANG_INFO);
    toOSIS(factory, ele, "LicenseInfo", COPYRIGHT_INFO);
    toOSIS(factory, ele, "FeatureInfo", FEATURE_INFO);
    toOSIS(factory, ele, "SysInfo", SYSTEM_INFO);
    toOSIS(factory, ele, "Extra", extra);
    return ele;
  }

  /**
   * Build's a SWORD conf file as a string. The result is not identical to the original, cleaning up
   * problems in the original and re-arranging the entries into a predictable order.
   *
   * @return the well-formed conf.
   */
  public String toConf() {
    StringBuilder buf = new StringBuilder();
    buf.append('[');
    buf.append(getValue(ConfigEntryType.INITIALS));
    buf.append("]\n");
    toConf(buf, BASIC_INFO);
    toConf(buf, SYSTEM_INFO);
    toConf(buf, HIDDEN);
    toConf(buf, FEATURE_INFO);
    toConf(buf, LANG_INFO);
    toConf(buf, COPYRIGHT_INFO);
    toConf(buf, extra);
    return buf.toString();
  }

  public void save() throws IOException {
    if (configFile != null) {
      // The encoding of the conf must match the encoding of the module.
      String encoding = ENCODING_LATIN1;
      if (getValue(ConfigEntryType.ENCODING).equals(ENCODING_UTF8)) {
        encoding = ENCODING_UTF8;
      }
      Writer writer = null;
      try {
        writer = new OutputStreamWriter(new FileOutputStream(configFile), encoding);
        writer.write(toConf());
      } finally {
        if (writer != null) {
          writer.close();
        }
      }
    }
  }

  public void save(File file) throws IOException {
    this.configFile = file;
    this.save();
  }

  private void loadContents(BufferedReader in) throws IOException {
    StringBuilder buf = new StringBuilder();
    while (true) {
      // Empty out the buffer
      buf.setLength(0);

      String line = advance(in);
      if (line == null) {
        break;
      }

      // skip blank lines
      if (line.length() == 0) {
        continue;
      }

      Matcher matcher = KEY_VALUE_PATTERN.matcher(line);
      if (!matcher.matches()) {
        log.warn("Expected to see '=' in " + internal + ": " + line);
        continue;
      }

      String key = matcher.group(1).trim();
      String value = matcher.group(2).trim();
      // Only CIPHER_KEYS that are empty are not ignored
      if (value.length() == 0 && !ConfigEntryType.CIPHER_KEY.getName().equals(key)) {
        log.warn("Ignoring empty entry in " + internal + ": " + line);
        continue;
      }

      // Create a configEntry so that the name is normalized.
      ConfigEntry configEntry = new ConfigEntry(internal, key);

      ConfigEntryType type = configEntry.getType();

      ConfigEntry e = table.get(type);

      if (e == null) {
        if (type == null) {
          log.warn("Extra entry in " + internal + " of " + configEntry.getName());
          extra.put(key, configEntry);
        } else if (type.isSynthetic()) {
          log.warn("Ignoring unexpected entry in " + internal + " of " + configEntry.getName());
        } else {
          table.put(type, configEntry);
        }
      } else {
        configEntry = e;
      }

      buf.append(value);
      getContinuation(configEntry, in, buf);

      // History is a special case it is of the form History_x.x
      // The config entry is History without the x.x.
      // We want to put x.x at the beginning of the string
      value = buf.toString();
      if (ConfigEntryType.HISTORY.equals(type)) {
        int pos = key.indexOf('_');
        value = key.substring(pos + 1) + ' ' + value;
      }

      configEntry.addValue(value);
    }
  }

  private void loadInitials(BufferedReader in) throws IOException {
    String initials = null;
    while (true) {
      String line = advance(in);
      if (line == null) {
        break;
      }

      if (line.charAt(0) == '[' && line.charAt(line.length() - 1) == ']') {
        // The conf file contains a leading line of the form [KJV]
        // This is the acronym by which Sword refers to it.
        initials = line.substring(1, line.length() - 1);
        break;
      }
    }
    if (initials == null) {
      log.error(
          "Malformed conf file for "
              + internal
              + " no initials found. Using internal of "
              + internal);
      initials = internal;
    }
    add(ConfigEntryType.INITIALS, initials);
  }

  /** Get continuation lines, if any. */
  private void getContinuation(ConfigEntry configEntry, BufferedReader bin, StringBuilder buf)
      throws IOException {
    for (String line = advance(bin); line != null; line = advance(bin)) {
      int length = buf.length();

      // Look for bad data as this condition did exist
      boolean continuation_expected = length > 0 && buf.charAt(length - 1) == '\\';

      if (continuation_expected) {
        // delete the continuation character
        buf.deleteCharAt(length - 1);
      }

      if (isKeyLine(line)) {
        if (continuation_expected) {
          log.warn(report("Continuation followed by key for", configEntry.getName(), line));
        }

        backup(line);
        break;
      } else if (!continuation_expected) {
        log.warn(report("Line without previous continuation for", configEntry.getName(), line));
      }

      if (!configEntry.allowsContinuation()) {
        log.warn(report("Ignoring unexpected additional line for", configEntry.getName(), line));
      } else {
        if (continuation_expected) {
          buf.append('\n');
        }
        buf.append(line);
      }
    }
  }

  /**
   * Get the next line from the input
   *
   * @param bin The reader to get data from
   * @return the next line
   * @throws IOException
   */
  private String advance(BufferedReader bin) throws IOException {
    // Was something put back? If so, return it.
    if (readahead != null) {
      String line = readahead;
      readahead = null;
      return line;
    }

    // Get the next non-blank, non-comment line
    String trimmed = null;
    for (String line = bin.readLine(); line != null; line = bin.readLine()) {
      // Remove trailing whitespace
      trimmed = line.trim();

      int length = trimmed.length();

      // skip blank and comment lines
      if (length != 0 && trimmed.charAt(0) != '#') {
        return trimmed;
      }
    }
    return null;
  }

  /** Read too far ahead and need to return a line. */
  private void backup(String oops) {
    if (oops.length() > 0) {
      readahead = oops;
    } else {
      // should never happen
      log.error("Backup an empty string for " + internal);
    }
  }

  /** Does this line of text represent a key/value pair? */
  private boolean isKeyLine(String line) {
    return KEY_VALUE_PATTERN.matcher(line).matches();
  }

  /**
   * A helper to create/replace a value for a given type.
   *
   * @param type
   * @param aValue
   */
  public void add(ConfigEntryType type, String aValue) {
    table.put(type, new ConfigEntry(internal, type, aValue));
  }

  private void adjustDataPath() {
    String datapath = (String) getValue(ConfigEntryType.DATA_PATH);
    if (datapath == null) {
      datapath = "";
    }
    if (datapath.startsWith("./")) {
      datapath = datapath.substring(2);
    }
    add(ConfigEntryType.DATA_PATH, datapath);
  }

  private void adjustLanguage() {
    Language lang = (Language) getValue(ConfigEntryType.LANG);
    if (lang == null) {
      lang = Language.DEFAULT_LANG;
      add(ConfigEntryType.LANG, lang.toString());
    }
    testLanguage(internal, lang);

    Language langFrom = (Language) getValue(ConfigEntryType.GLOSSARY_FROM);
    Language langTo = (Language) getValue(ConfigEntryType.GLOSSARY_TO);

    // If we have either langFrom or langTo, we are dealing with a glossary
    if (langFrom != null || langTo != null) {
      if (langFrom == null) {
        log.warn(
            "Missing data for "
                + internal
                + ". Assuming "
                + ConfigEntryType.GLOSSARY_FROM.getName()
                + '='
                + Languages.DEFAULT_LANG_CODE);
        langFrom = Language.DEFAULT_LANG;
        add(ConfigEntryType.GLOSSARY_FROM, lang.getCode());
      }
      testLanguage(internal, langFrom);

      if (langTo == null) {
        log.warn(
            "Missing data for "
                + internal
                + ". Assuming "
                + ConfigEntryType.GLOSSARY_TO.getName()
                + '='
                + Languages.DEFAULT_LANG_CODE);
        langTo = Language.DEFAULT_LANG;
        add(ConfigEntryType.GLOSSARY_TO, lang.getCode());
      }
      testLanguage(internal, langTo);

      // At least one of the two languages should match the lang entry
      if (!langFrom.equals(lang) && !langTo.equals(lang)) {
        log.error(
            "Data error in "
                + internal
                + ". Neither "
                + ConfigEntryType.GLOSSARY_FROM.getName()
                + " or "
                + ConfigEntryType.GLOSSARY_FROM.getName()
                + " match "
                + ConfigEntryType.LANG.getName());
      } else if (!langFrom.equals(lang)) {
        // The LANG field should match the GLOSSARY_FROM field
        /*
         * log.error("Data error in " + internal + ". " +
         * ConfigEntryType.GLOSSARY_FROM.getName() + " ("
         * + langFrom.getCode() + ") does not match " +
         * ConfigEntryType.LANG.getName() + " (" + lang.getCode() +
         * ")");
         */
        lang = langFrom;
        add(ConfigEntryType.LANG, lang.getCode());
      }
    }
  }

  private void adjustBookType() {
    // The book type represents the underlying category of book.
    // Fine tune it here.
    BookCategory focusedCategory = (BookCategory) getValue(ConfigEntryType.CATEGORY);
    questionable = focusedCategory == BookCategory.QUESTIONABLE;

    // From the config map, extract the important bean properties
    String modTypeName = (String) getValue(ConfigEntryType.MOD_DRV);
    if (modTypeName == null) {
      log.error(
          "Book not supported: malformed conf file for "
              + internal
              + " no "
              + ConfigEntryType.MOD_DRV.getName()
              + " found");
      supported = false;
      return;
    }

    bookType = BookType.fromString(modTypeName);
    if (getBookType() == null) {
      log.error("Book not supported: malformed conf file for " + internal + " no book type found");
      supported = false;
      return;
    }

    BookCategory basicCategory = getBookType().getBookCategory();
    if (basicCategory == null) {
      supported = false;
      return;
    }

    // The book type represents the underlying category of book.
    // Fine tune it here.
    if (focusedCategory == BookCategory.OTHER || focusedCategory == BookCategory.QUESTIONABLE) {
      focusedCategory = getBookType().getBookCategory();
    }

    add(ConfigEntryType.CATEGORY, focusedCategory.getName());
  }

  private void adjustName() {
    // If there is no name then use the internal name
    if (table.get(ConfigEntryType.DESCRIPTION) == null) {
      log.error(
          "Malformed conf file for "
              + internal
              + " no "
              + ConfigEntryType.DESCRIPTION.getName()
              + " found. Using internal of "
              + internal);
      add(ConfigEntryType.DESCRIPTION, internal);
    }
  }

  /** Determine which books are not supported. Also, report on problems. */
  private void validate() {
    // if (isEnciphered())
    // {
    //            log.debug("Book not supported: " + internal + " because it is locked and there is
    // no key.");
    // supported = false;
    // return;
    // }
  }

  private void testLanguage(String initials, Language lang) {
    if (!lang.isValidLanguage()) {
      log.warn("Unknown language " + lang.getCode() + " in book " + initials);
    }
  }

  /** Build an ordered map so that it displays in a consistent order. */
  private void toOSIS(
      OSISUtil.OSISFactory factory, Element ele, String aTitle, ConfigEntryType[] category) {
    Element title = null;
    for (int i = 0; i < category.length; i++) {
      ConfigEntry entry = table.get(category[i]);
      Element configElement = null;

      if (entry != null) {
        configElement = entry.toOSIS();
      }

      if (title == null && configElement != null) {
        // I18N(DMS): use aTitle to lookup translation.
        title = factory.createHeader();
        title.addContent(aTitle);
        ele.addContent(title);
      }

      if (configElement != null) {
        ele.addContent(configElement);
      }
    }
  }

  private void toConf(StringBuilder buf, ConfigEntryType[] category) {
    for (int i = 0; i < category.length; i++) {

      ConfigEntry entry = table.get(category[i]);

      if (entry != null && !entry.getType().isSynthetic()) {
        String text = entry.toConf();
        if (text != null && text.length() > 0) {
          buf.append(entry.toConf());
        }
      }
    }
  }

  /** Build an ordered map so that it displays in a consistent order. */
  private void toOSIS(
      OSISUtil.OSISFactory factory, Element ele, String aTitle, Map<String, ConfigEntry> map) {
    Element title = null;
    for (Map.Entry<String, ConfigEntry> mapEntry : map.entrySet()) {
      ConfigEntry entry = mapEntry.getValue();
      Element configElement = null;

      if (entry != null) {
        configElement = entry.toOSIS();
      }

      if (title == null && configElement != null) {
        // I18N(DMS): use aTitle to lookup translation.
        title = factory.createHeader();
        title.addContent(aTitle);
        ele.addContent(title);
      }

      if (configElement != null) {
        ele.addContent(configElement);
      }
    }
  }

  private void toConf(StringBuilder buf, Map<String, ConfigEntry> map) {
    for (Map.Entry<String, ConfigEntry> mapEntry : map.entrySet()) {
      ConfigEntry entry = mapEntry.getValue();
      String text = entry.toConf();
      if (text != null && text.length() > 0) {
        buf.append(text);
      }
    }
  }

  private String report(String issue, String confEntryName, String line) {
    StringBuilder buf = new StringBuilder(100);
    buf.append(issue);
    buf.append(' ');
    buf.append(confEntryName);
    buf.append(" in ");
    buf.append(internal);
    buf.append(": ");
    buf.append(line);

    return buf.toString();
  }

  /**
   * Sword only recognizes two encodings for its modules: UTF-8 and LATIN1 Sword uses MS Windows
   * cp1252 for Latin 1 not the standard. Arrgh!
   */
  private static final String ENCODING_UTF8 = "UTF-8";

  private static final String ENCODING_LATIN1 = "WINDOWS-1252";

  /**
   * These are the elements that JSword requires. They are a superset of those that Sword requires.
   */
  /*
   * For documentation purposes at this time. private static final
   * ConfigEntryType[] REQUIRED = { ConfigEntryType.INITIALS,
   * ConfigEntryType.DESCRIPTION, ConfigEntryType.CATEGORY, // may not be
   * present in conf ConfigEntryType.DATA_PATH, ConfigEntryType.MOD_DRV, };
   */

  private static final ConfigEntryType[] BASIC_INFO = {
    ConfigEntryType.INITIALS,
    ConfigEntryType.DESCRIPTION,
    ConfigEntryType.CATEGORY,
    ConfigEntryType.LCSH,
    ConfigEntryType.SWORD_VERSION_DATE,
    ConfigEntryType.VERSION,
    ConfigEntryType.HISTORY,
    ConfigEntryType.OBSOLETES,
    ConfigEntryType.INSTALL_SIZE,
  };

  private static final ConfigEntryType[] LANG_INFO = {
    ConfigEntryType.LANG, ConfigEntryType.GLOSSARY_FROM, ConfigEntryType.GLOSSARY_TO,
  };

  private static final ConfigEntryType[] COPYRIGHT_INFO = {
    ConfigEntryType.ABOUT,
    ConfigEntryType.SHORT_PROMO,
    ConfigEntryType.DISTRIBUTION_LICENSE,
    ConfigEntryType.DISTRIBUTION_NOTES,
    ConfigEntryType.DISTRIBUTION_SOURCE,
    ConfigEntryType.SHORT_COPYRIGHT,
    ConfigEntryType.COPYRIGHT,
    ConfigEntryType.COPYRIGHT_DATE,
    ConfigEntryType.COPYRIGHT_HOLDER,
    ConfigEntryType.COPYRIGHT_CONTACT_NAME,
    ConfigEntryType.COPYRIGHT_CONTACT_ADDRESS,
    ConfigEntryType.COPYRIGHT_CONTACT_EMAIL,
    ConfigEntryType.COPYRIGHT_CONTACT_NOTES,
    ConfigEntryType.COPYRIGHT_NOTES,
    ConfigEntryType.TEXT_SOURCE,
  };

  private static final ConfigEntryType[] FEATURE_INFO = {
    ConfigEntryType.FEATURE, ConfigEntryType.GLOBAL_OPTION_FILTER, ConfigEntryType.FONT,
  };

  private static final ConfigEntryType[] SYSTEM_INFO = {
    ConfigEntryType.DATA_PATH,
    ConfigEntryType.MOD_DRV,
    ConfigEntryType.SOURCE_TYPE,
    ConfigEntryType.BLOCK_TYPE,
    ConfigEntryType.BLOCK_COUNT,
    ConfigEntryType.COMPRESS_TYPE,
    ConfigEntryType.ENCODING,
    ConfigEntryType.MINIMUM_VERSION,
    ConfigEntryType.OSIS_VERSION,
    ConfigEntryType.OSIS_Q_TO_TICK,
    ConfigEntryType.DIRECTION,
    ConfigEntryType.KEY_TYPE,
    ConfigEntryType.DISPLAY_LEVEL,
  };

  private static final ConfigEntryType[] HIDDEN = {
    ConfigEntryType.CIPHER_KEY,
  };

  /** The log stream */
  private static final Logger log = Logger.getLogger(ConfigEntryTable.class);

  /**
   * The original name of this config file from mods.d. This is only used for managing warnings and
   * errors
   */
  private String internal;

  /** A map of lists of known config entries. */
  private Map<ConfigEntryType, ConfigEntry> table;

  /** A map of lists of unknown config entries. */
  private Map<String, ConfigEntry> extra;

  /** The BookType for this ConfigEntry */
  private BookType bookType;

  /** True if this book's config type can be used by JSword. */
  private boolean supported;

  /** True if this book is considered questionable. */
  private boolean questionable;

  /** A helper for the reading of the conf file. */
  private String readahead;

  /** If the module's config is tied to a file remember it so that it can be updated. */
  private File configFile;

  /**
   * Pattern that matches a key=value. The key can contain ascii letters, numbers, underscore and
   * period. The key must begin at the beginning of the line. The = sign following the key may be
   * surrounded by whitespace. The value may contain anything, including an = sign.
   */
  private static final Pattern KEY_VALUE_PATTERN =
      Pattern.compile("^([A-Za-z0-9_.]+)\\s*=\\s*(.*)$");
}