001/* A program to make a web site from a database
002 *
003 * Copyright (C) 2015 Sidney Marshall (swm@cs.rit.edu)
004 *
005 * This program is free software: you can redistribute it and/or
006 * modify it under the terms of the GNU General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful, but
011 * WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
013 * General Public License for more details.
014 *
015 * You should have received a copy of the GNU General Public License
016 * along with this program.  If not, see
017 * <http://www.gnu.org/licenses/>.
018 */
019
020import java.io.File;
021import java.io.FileWriter;
022import java.io.IOException;
023import java.sql.Connection;
024import java.sql.DriverManager;
025import java.sql.PreparedStatement;
026import java.sql.ResultSet;
027import java.sql.ResultSetMetaData;
028import java.sql.SQLException;
029import java.sql.Statement;
030import java.util.ArrayList;
031import java.util.Arrays;
032import java.util.Comparator;
033
034/*
035 * java -cp sqlite-jdbc-3.7.8-20111025.014814-1.jar\;. MakeWeb
036 * use : separator on linux
037 *
038 * jar file can have class path specified
039*/
040/* full join:
041 *
042 * SELECT * FROM table_name_1
043 *   LEFT OUTER JOIN table_name_2 ON id_1 = id_2
044 * UNION SELECT * FROM table_name_2
045 *   LEFT OUTER JOIN table_name_1 ON id_1 = id_2 ;
046 */
047
048// Note that 'xxx' is a string and "xxx" is an identifier.
049
050/* ordered group_concat
051 *
052 * select parent_name, group_concat(child_name)
053 * from
054 *   (select parent_name, child_name from children order by child_name)
055 * group by parent_name;
056 */
057
058/**
059 * This class creates a web site from a database
060 */
061public class MakeWeb {
062  static final boolean printFlag = true;
063  static Connection conn; // the database connection
064
065  /**
066   * This method implements easy access to the specified rows of a
067   * query.  The method queries the database and sets up parameters to
068   * read specified columns from the returned table. If the number of
069   * specified columns is zero then all columns are read in
070   * order. This constructor will cause errors if a column name is
071   * requested that does not exist. If there is no result set then an
072   * empty result is returned allowing this method to be used for
073   * general sql statements.
074   *
075   * @param query the query used for obtaining the table
076   * @param columns the names of the columns to be returned
077   * @return an ArrayList of ArrayList of the result of a query
078   * @throws SQLException throws if problem with SQLite
079   */
080  static ArrayList<ArrayList<String>> table(String query, String...columns)
081    throws SQLException {
082    if(query == null) throw new SQLException("null query in table");
083    int ncolumns = columns.length;
084    ArrayList<ArrayList<String>> result = new ArrayList<ArrayList<String>>();
085    Statement stat = conn.createStatement();
086    try {
087      stat.execute(query);
088    } catch(SQLException e) {
089      System.out.println("Bad SQL statement in table function:");
090      System.out.println(query);
091      throw e;
092    }
093    int count = stat.getUpdateCount();
094    if(count >= 0) {
095      stat.close();
096      return result;
097    }
098    ResultSet rs = stat.getResultSet();
099    ResultSetMetaData md = rs.getMetaData();
100    int tablecolumns = md.getColumnCount();
101    ArrayList<Integer> columnIndex = new ArrayList<Integer>();
102    if(columns.length > 0) {
103      ArrayList<String> columnName = new ArrayList<String>();
104      columnName.add(""); // 0th entry
105      for(int i = 1; i <= tablecolumns; i++) {
106        columnName.add(md.getColumnName(i));
107      }
108      iloop: for(int i = 0; i < columns.length; i++) {
109        for(int j = 1; j <= tablecolumns; j++) {
110          if(columns[i].equals(columnName.get(j))) {
111            columnIndex.add(j);
112            continue iloop;
113          }
114        }
115        columnIndex.add(null); // error - no such column name
116      }
117    } else {
118      ncolumns = tablecolumns;
119      for(int i = 0; i <= tablecolumns; i++) {
120        columnIndex.add(i + 1);
121      }
122    }
123    while(rs.next()) {
124      ArrayList<String> data = new ArrayList<String>();
125        for(int i = 0; i < ncolumns; i++) {
126          data.add(rs.getString(columnIndex.get(i)));
127        }
128        result.add(data);
129    }
130    stat.close();
131    return result;
132  }
133
134  /**
135   * This method creates all pages specified by the database. It
136   * assumes three tables in the database: substitutions, pagesets,
137   * and scripts.
138   *
139   * substitutions(pageset, substitutions, comment)
140   *   pageset - identifies the type of page being generated (unique)
141   *   substitutions - an sql statement returning a table:
142   *     first field is file name or null, rest replace $1-$9 (and $$)
143   *   comment - a human comment telling what scripts in this line do
144   *
145   * pagesets(pageset, position, pagepart, comment)
146   *   pageset - identifies the type of page being generated
147   *   position - order in which to run pageparts in a pageset
148   *   pagepart - an index into the scripts table for the scripts
149   *   comment - a human comment telling what scripts in this line do
150   *
151   * scripts(pagepart, position, script, comment)
152   *   pagepart - identifies the part of the page being generated
153   *   position - order in which to run scripts in a pagepart
154   *   script - an sql statement returning a "page" segment
155   *     result each row is fields concatenated to be written to file
156   *   comment - a human comment telling what scripts in this line do
157   *
158   * ---------- for reference only ----------
159   * styles(selector, property, value, comment)
160   *   selector - a css selector
161   *   property - a css property name
162   *   value - the value of this property
163   *   comment - a human comment telling where this property is used
164   *
165   * ---------- algorithm ----------
166   *
167   * for each substitution
168   *   get table of substitutions
169   *   for each substitution (each table row)
170   *     get and open file specified by first column (or no file for null)
171   *     get matching pageset in order from pagesets and scripts
172   *       for each script (after substitutions)
173   *         for each line
174   *           concatenate fields and write line to file
175   *     close file
176   * @param pagesets pagesets to be made - if empty all pagesets are made
177   * @throws SQLException catch-all for SQLExceptions
178   * @throws IOException catch-all for IOExceptions
179   */
180  static void make(String...pagesets) throws SQLException, IOException {
181    // we need error messages that tell where we are in the scripts
182    // Get and process pagegroups from master table substitutions
183    long totalTime = 0;
184    ArrayList<ArrayList<String>> subs = table("select * from substitutions order by pageset",
185                           "pageset", "substitutions");
186    Arrays.sort(pagesets);
187    for(ArrayList<String> subsLine : subs) {
188      String pageset = subsLine.get(0);
189      if(pagesets.length != 0
190         && Arrays.binarySearch(pagesets, pageset) < 0) continue;
191      if(printFlag) System.out.print("pageset: " + pageset + " - ");
192      long startTime = System.nanoTime();
193      // run substitution sql to get list of substitutions for pagegroup
194      ArrayList<ArrayList<String>> subsList = table(subsLine.get(1));
195      for(ArrayList<String> subsListLine : subsList) {
196        String filename = subsListLine.get(0);
197        FileWriter out = null;
198        if(filename != null) {
199          out = new FileWriter(filename);
200        }
201        // get scripts for pageset
202        ArrayList<ArrayList<String>> scripts
203          = table("select script from pagesets join scripts where pageset is '" + pageset + "' and pagesets.pagepart is scripts.pagepart order by pagesets.position, scripts.position");
204        for(ArrayList<String> scriptsLine : scripts) {
205          // run substituted sql to get the tables to write out
206          if(filename != null) {
207            ArrayList<ArrayList<String>> page = table(substitute(scriptsLine.get(0), subsListLine));
208            for(ArrayList<String> pageLine : page) {
209              // write scriptsLine concatenated
210              StringBuilder sb = new StringBuilder();
211              for(int i = 0; i < pageLine.size(); i++) {
212                String item = pageLine.get(i);
213                if(item != null) sb.append(item);
214              }
215              sb.append('\n');
216              out.write(sb.toString());
217            }
218          } else {
219            String s = substitute(scriptsLine.get(0), subsListLine);
220            Statement stat = conn.createStatement();
221            try {
222              stat.execute(s);
223            } catch(SQLException e) {
224              System.out.println("In pageset: " + pageset + " filename: " + filename + " command: " + s);
225              throw e;
226            }
227            stat.close();
228          }
229        }
230        if(out != null) {
231          out.close();
232        }
233      }
234      long stopTime = System.nanoTime();
235      long milliseconds = (stopTime - startTime) / 1000000L;
236      totalTime += milliseconds;
237      if(printFlag) System.out.println(milliseconds + " msec");
238    }
239    if(printFlag) System.out.println("Total Time = " + totalTime);
240  }
241
242  /**
243   * replaces $0-$9 in String s with subs.get(0) - subs.get(9). Also
244   * replaces $$ with $. (Note that $0 is normally replaced by the
245   * file name.)
246   *
247   * @param s the String to perform substitutions on
248   * @param subs an ArrayList specifying substitutions for $0, $1 etc.
249   * @return the string with substitutions
250   */
251  static String substitute(String s, ArrayList<String> subs) {
252    StringBuilder sb = new StringBuilder();
253    for(int i = 0; i < s.length(); i++) {
254      char c;
255      if((c = s.charAt(i)) != '$' || i == s.length() - 1) {
256        sb.append(c);
257      } else {
258        i++;
259        c = s.charAt(i);
260        int val = Character.digit(c, 10);
261        if(val < subs.size()) {
262          sb.append(subs.get(val));
263        } else if(c == '$') {
264          sb.append(c);
265        } else {
266          sb.append('$');
267          sb.append(c);
268        }
269      }
270    }
271    return sb.toString();
272  }
273
274  /**
275   * if null return the string "null" otherwise surround the string
276   * with ' and escape ' characters by '' to allow in SQL
277   * statements. Commented out because now using ? for all variable
278   * strings and this handles nulls too.
279   *
280   * We are now using prepared statements and '?' so this method is
281   * not currently used.
282   *
283   * @param s the string to be escaped
284   * @return the escaped string
285   */
286  /*
287  static String s(String s) {
288    if(s == null) return "null";
289    return "'" + s.replace("'", "''") + "'";
290  }
291  */
292  /**
293   * An interface defining a callback for a file tree walk (used by
294   * the sitemap option)
295   */
296  static interface FileCallBack {
297    /**
298     * @param entryid a unique reference for this file system object
299     * @param name the name of directory entry
300     * @param type 0 = file, 1 = dir, -1 = unknown (, 2 = link etc.)
301     * @param path the path from root to this file object
302     * @param parentid a unique reference of containing directory
303     * @param length length of the file
304     * @param date the modification date of the file
305     */
306    void file(int entryid, String name, int type, String path, int parentid, long length, long date);
307  }
308
309  /**
310   * This class, given a top-level directory, creates in a database a
311   * table "sitemap" containing entries representing the file and
312   * directory structure under this top-level directory.  The format of
313   * the database tables is:
314   *
315   * Command: java -cp makeweb.jar MakeWeb\$SiteMap test.db map .
316   *
317   * Table sitemap(parent, child)
318   *
319   * The top-level directory is represented by the empty string and all
320   * other files are named relative to this top-level directory
321   */
322  static class SiteMap {
323
324    /**
325     * Gets the canonical name for a File or returns the name
326     * "UNKNOWN"
327     *
328     * @param f the file name to be canonized
329     * @return the canonical file name
330     */
331    static String canonicalName(File f) {
332      try {
333        return f.getCanonicalPath();
334      } catch(Exception e) {
335        e.printStackTrace();
336      }
337      return "UNKNOWN";
338    }
339
340    /**
341     * Return a relative path relative to the given absolute path. If
342     * the prefix is not a prefix of the absolute path just return the
343     * unmodified absolute path.
344     *
345     * @param prefix the prefix to be stripped off of the path
346     * @param absolute the path to be stripped
347     * @return the stripped path
348     */
349    static String relativePath(String prefix, String absolute) {
350      int index = absolute.indexOf(prefix);
351      if(index == 0 && prefix.length() != absolute.length()) {
352        String suffix = absolute.substring(prefix.length());
353        if(suffix.charAt(0) == File.separatorChar) {
354          suffix = suffix.substring(1);
355        }
356        return suffix;
357      }
358      return absolute;
359    }
360
361    String topBase;
362    int id = 0; // current unique id
363    Connection conn; // the database connection
364    String table; // the table to update
365    PreparedStatement ustat;
366
367    /**
368     * Insert into a database a file map of the specified
369     * directory. The specified table is cleared before inserting the
370     * file map. The table should be created with at least the
371     * columns:
372     *
373     * create table xxx(entryid, name, type, path, parentid, length, date)
374     *
375     * @param conn the connection to the database
376     * @param table the name of the table to use
377     * @param top the top of the file system to create a map on
378     */
379    SiteMap(Connection conn, String table, File top) {
380      this.conn = conn;
381      this.table = table;
382      String updateQuery = "insert into " + table +
383        "(entryid, name, type, path, parentid, length, date)" +
384        "values(?1,?2,?3,?4,?5,?6,?7)";
385      Statement stat;
386      try {
387        stat = conn.createStatement();
388        stat.executeUpdate("begin");
389        stat.executeUpdate("delete from " + table);
390        ustat = conn.prepareStatement(updateQuery);
391        topBase = canonicalName(top);
392        siteMap(new File(topBase), id, new DBInsert()); // top of hierarchy is 0
393        ustat.close();
394        stat.executeUpdate("end");
395        stat.close();
396      } catch(SQLException e) {
397        e.printStackTrace();
398      }
399    }
400
401    /**
402     * Extend a site map for a directory - argument must be a
403     * directory and must exist. Second argument must be a canonical
404     * name string for the parent
405     *
406     * We want to fill in a sitemap table:
407     *
408     * sitemap(entryid, name, path, parentid, length, date)
409     *
410     * @param parentDir the current directory we are working on
411     * @param parent its unique id of the directory we are working on
412     * @param callBack the callback object for dealing with a child
413     */
414    void siteMap(File parentDir, int parent, FileCallBack callBack) {
415      File[] childFiles = parentDir.listFiles();
416      Arrays.sort(childFiles, new Comparator<File>(){
417          public int compare(File f1, File f2) {
418            return f1.getName().compareToIgnoreCase(f2.getName());
419          }
420        });
421      for(int i = 0; i < childFiles.length; i++) {
422        File childFile = new File(parentDir, childFiles[i].getName());
423        String child = childFiles[i].getName();
424        String fullName = relativePath(topBase, childFiles[i].getAbsolutePath());
425        long length = childFile.length();
426        long date = childFile.lastModified();
427        ++id;
428        if(childFile.isFile()) {
429          callBack.file(id, child, 0, fullName, parent, length, date);
430        } else if(childFile.isDirectory()) {
431          callBack.file(id, child, 1, fullName, parent, length, date);
432          siteMap(childFile, id, callBack);
433        } else {
434          callBack.file(id, child, -1, fullName, parent, length, date);
435        }
436      }
437    }
438
439    /**
440     * insert into tablename (name1, name2) values(exp, exp)
441     *
442     * Suggest inserting:
443     *
444     * entryid - id of this entry
445     * name - text name of entry
446     * type - 0 is file, 1 is directory, -1 is unknown
447     * path - string path from top
448     * parentid - id of containing directory with top = 0
449     * length - byte length of file, 0 for directory on windows, size on linux
450     * date - milliseconds since the epoch (00:00:00 GMT, January 1, 1970)
451     */
452    class DBInsert implements FileCallBack {
453      /*
454        query = insert into <table> (entryid, name, type, path, parentid, length, date)
455                values(?1,?2,?3,?4,?5,?6,?7)
456      */
457      /**
458       * The prepared statement ustat is:
459       *
460       * insert into &lt;table&gt; (entryid, name, type, path, parentid, length, date)
461       *
462       * Insert the arguments into the database.
463       *
464       * @param entryid a unique reference for this file system object
465       * @param name the name of directory entry
466       * @param type 0 = file, 1 = dir, -1 = unknown (, 2 = link etc.)
467       * @param path the path from root to this file object
468       * @param parentid a unique reference of containing directory
469       * @param length length of the file
470       * @param date the modification date of the file
471       */
472      public void file(int entryid, String name, int type, String path, int parentid, long length, long date) {
473        try {
474        ustat.setInt(1, entryid);
475        ustat.setString(2, name);
476        ustat.setInt(3, type);
477        ustat.setString(4, path.replace('\\', '/'));
478        ustat.setInt(5, parentid);
479        ustat.setLong(6, length);
480        ustat.setLong(7, date);
481        ustat.executeUpdate();
482        } catch(SQLException e) {
483          e.printStackTrace();
484        }
485      }
486    } // class DBInsert implements FileCallBack
487
488    /**
489     * arg0 = database file name<br>
490     * arg1 = database table name<br>
491     * arg2 = name of top of file system<br><br>
492     *
493     * can be called from command line by:<br><br>
494     *
495     * java -cp makeweb.jar MakeWeb\$SiteMap test.db map .<br><br>
496     *
497     * where "." can be replaced by any existing directory
498     * @param args command line arguments
499     */
500    public static void main(String...args) {
501      Connection conn;
502      try {
503        Class.forName("org.sqlite.JDBC");
504        conn = DriverManager.getConnection("jdbc:sqlite:" + args[0]);
505        File top = new File(args[2]);
506        new SiteMap(conn, args[1], top);
507      } catch(ClassNotFoundException ex) {
508        ex.printStackTrace();
509      } catch(SQLException ex) {
510        ex.printStackTrace();
511      }
512    }
513  } // class SiteMap
514
515  /**
516   * If first arg is "-map" then a sitemap structure is created in the
517   * table "sitemap". If there is a second argument it will be used as
518   * the root of the filemap, otherwise the root will be ".".
519   *
520   * All subsequent arguments are the "pagesets" to be made. If there
521   * are no subsequent arguments then all "pagesets" are made. Note
522   * that skipping some pagesets can result in errors.
523   *
524   * @param args command line arguments
525   * @throws SQLException catch-all for SQLExceptions
526   * @throws IOException catch-all for IOExceptions
527   */
528  public static void main(String...args) throws SQLException, IOException {
529    String root = ".";
530    /*
531    if(args.length > 2) {
532      System.err.println("Usage: java -jar makeweb [-map [root]]");
533      return;
534    }
535    */
536    int index = 0; // where pagesets list (if any) starts
537    try {
538      Class.forName("org.sqlite.JDBC");
539    } catch(ClassNotFoundException ex) {
540      ex.printStackTrace();
541    }
542    conn = DriverManager.getConnection("jdbc:sqlite:" + "squaredance.db");
543    if(args.length >= 1) {
544      if(args[0].equals("-map")) {
545        index = 1;
546        if(args.length >= 2) {
547          root = args[1];
548          index = 2;
549        }
550        File top = new File(root);
551        new SiteMap(conn, "sitemap", top);
552      }
553    }
554
555    ArrayList<String> subs = new ArrayList<String>();
556    for(int i = index; i < args.length; i++) {
557      subs.add(args[i]);
558    }
559
560    make(subs.toArray(new String[0]));
561    System.out.println("Done.");
562  }
563} // class MakeWeb