//
// The Antville Project
// http://code.google.com/p/antville
//
// Copyright 2001-2007 by The Antville People
//
// Licensed under the Apache License, Version 2.0 (the ``License'');
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//    http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an ``AS IS'' BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// $Revision$
// $LastChangedBy$
// $LastChangedDate$
// $URL$
//

app.addRepository(app.dir + "/../lib/rome-0.9.jar");
app.addRepository(app.dir + "/../lib/jdom.jar");
app.addRepository(app.dir + "/../lib/itunes-0.4.jar");

app.addRepository("modules/core/Global.js");
app.addRepository("modules/core/HopObject.js");
app.addRepository("modules/core/Number.js");
app.addRepository("modules/core/Filters.js");

app.addRepository("modules/helma/Image.js");
app.addRepository("modules/helma/Html.js");
app.addRepository("modules/helma/Http.js");
app.addRepository("modules/helma/Mail.js");
app.addRepository("modules/helma/Search.js");
app.addRepository("modules/helma/Zip.js");

app.addRepository("modules/jala/code/Date.js");
//app.addRepository("modules/jala/code/Form.js");
app.addRepository("modules/jala/code/HopObject.js");
app.addRepository("modules/jala/code/I18n.js");
//app.addRepository("modules/jala/code/IndexManager.js");
//app.addRepository("modules/jala/code/ListRenderer.js");
app.addRepository("modules/jala/code/ListRenderer.js");
app.addRepository("modules/jala/code/Utilities.js");

app.data.mails || (app.data.mails = []);
app.data.stories || (app.data.stories = {});

var rome = new JavaImporter(
   Packages.com.sun.syndication.feed.synd,
   Packages.com.sun.syndication.io.SyndFeedOutput,
   Packages.com.sun.syndication.feed.module.itunes,
   Packages.com.sun.syndication.feed.module.itunes.types
);

var html = new helma.Html();

var idle = new Function;

SHORTDATEFORMAT = "yyyy-MM-dd HH:mm";
LONGDATEFORMAT = "EEEE, d. MMMM yyyy, HH:mm";

/**
 * Extend the Mail prototype with a method that simply adds a mail object 
 * to an application-wide array (mail queue).
 */
helma.Mail.prototype.queue = function() {
   return app.data.mails.push(this);
}

helma.Mail.flushQueue = function() {
   if (app.data.mails.length > 0) {
      app.debug("Flushing mail queue, sending " + 
            app.data.mails.length + " messages");
      var mail;
      while (app.data.mails.length > 0) {
         mail = app.data.mails.pop();
         mail.send();
         if (mail.status > 0) {
            app.debug("Error while sending e-mail (status " + mail.status + ")");
            mail.writeToFile(getProperty("smtp.dir"));
         }
      }
   }
   return;
}

jala.i18n.setLocaleGetter(function() {
   return res.handlers.site.getLocale();
});

function onStart() {
   // FIXME: Does database exist?
   /*var db = getDBConnection("antville");
   var rows = db.executeRetrieval("select min(id) as id from site");
   rows.next();
   var id = rows.getColumnItem("id");
   //Packages.helma.main.Server.getServer().stopApplication(app.name);
   rows.release();*/

   // call onStart methods of modules
   for (var i in app.modules) {
      if (app.modules[i].onStart)
         app.modules[i].onStart();
   }
   return;
}

function defineConstants(ctor /*, arguments */) {
   var constants = [], name;
   for (var i=1; i<arguments.length; i+=1) {
      name = arguments[i].toUpperCase().replace(/\s/g, "");
      if (ctor[name]) {
         //app.logger.warn("Constant already defined: " + ctor.name + "." + name);
      }
      ctor[name] = arguments[i];
      constants.push({
         value: arguments[i],
         display: arguments[i]
      });
   }
   return function() {
      return constants;
   };
}

function disableMacro(ctor, name) {
   return ctor.prototype[name + "_macro"] = idle;
}

function scheduler() {
   flushLog();
   Stories.flushRequests();
   helma.Mail.flushQueue();
   // FIXME: root.manage.autoCleanUp();
   // FIXME: pingUpdatedSites();
   // FIXME: countUsers();
   //app.data.lastAccessLogUpdate = new Date();
   return 5000;
}

function logAction(context, action) {
   if (context) {
      root.admin.log.add(new LogEntry(context, action));      
   } else {
      root.admin.log.cache.add(new LogEntry(path[path.length - 1]));
   }
   return;
   /* FIXME: check whether request really should be logged
   var site = res.handlers.site ? res.handlers.site: root;
   var url = http.evalUrl(req.data.http_referer);

   // no logging at all if the referrer comes from the same site
   // or is not a http-request
   if (!url)
      return;
   var referrer = url.toString();
   var siteHref = site.href().toLowerCase();
   if (referrer.toLowerCase().contains(siteHref.substring(0, siteHref.length-1)))
      return;
   var logObj = new Object();
   logObj.storyID = path.Story ? path.Story._id : null;
   logObj.siteID = site._id;
   logObj.referrer = referrer;
   logObj.remoteHost = req.data.http_remotehost;
   logObj.browser = req.data.http_browser;
   */
   return root.admin.syslogs.add(new SysLog(type, object, text, session.user));   
}

function flushLog() {
   var log = root.admin.log;
   var n = log.cache.size();
   if (n < 1) {
      return;
   }
   log.cache.forEach(function() {
      return log.add(this);
   });
   res.commit();
   log.clearCache();
   app.logger.info("Wrote " + n + " log entries to DB");
   return;
}

/**
 * Renders a string depending on the comparison of two values. If the first 
 * value equals the second value, the first result will be returned; the 
 * second result otherwise.
 * <p>Example: <code>&lt;% if &lt;% macro %&gt; is "value" then "yes!" else "no :(" %&gt;</code>
 * </p>
 * Note that any value or result can be a macro, too. Thus, this can be used as
 * a simple implementation of an if-then-else statement by using Helma macros
 * only. 
 * @param {Object} param The default Helma macro parameter object
 * @param {String} firstValue The first value
 * @param {String} _is_ Syntactic sugar; should be "is" for legibility
 * @param {String} secondValue The second value
 * @param {String} _then_ Syntactic sugar; should be "then" for legibility
 * @param {String} firstResult The first result, ie. the value that will be 
 * returned if the first value equals the second one
 * @param {String} _else_ Syntactic sugar; should be "else" for legibility
 * @param {String} secondResult The second result, ie. the value that will be 
 * returned if the first value does not equal the second one
 * @returns The resulting value
 * @type String
 * @member Global
 */
function if_macro(param, firstValue, _is_, secondValue, _then_, firstResult, 
      _else_, secondResult) {
   return (("" + firstValue) == ("" + secondValue)) ? firstResult : secondResult;
}

function gettext_macro(param, text /*, value1, value2, ...*/) {
   var args = [text];
   for (var i=2; i<arguments.length; i+=1) {
      args.push(arguments[i]);
   }
   return gettext.apply(this, args);
}

function ngettext_macro(param, singular, plural, value1 /*, value2, value3, ...*/) {
   var args = [singular, plural, value1];
   for (var i=4; i<arguments.length; i+=1) {
      args.push(arguments[i]);
   }
   return ngettext.apply(this, args);
}

function now_macro(param, format) {
   return formatDate(new Date, format || param.format);
}

function link_macro() {
   return renderLink.apply(this, arguments);
}

function renderLink(param, url, text, handler) {
   url || (url = param.url || "");
   text || (text = param.text || url);
   if (!text) {
      return;
   }
   delete param.url;
   delete param.text;
   param.title || (param.title = String.EMPTY);
   if (!handler || url.contains(":")) {
      param.href = url;
   } else if (url.contains("/") || url.contains("?") || url.contains("#")) {
      var parts = url.split(/(\/|\?|#)/);
      param.href = handler.href(parts[0]) + parts.splice(1).join(String.EMPTY);
   } else {
      param.href = handler.href(url);
   }
   html.link(param, text);
   return;
}

function breadcrumbs_macro (param, delimiter) {
   delimiter || (delimiter = param.separator || " : ");
   var offset = res.handlers.site === root ? 0 : 1;
   for (var i=offset; i<path.length-1; i+=1) {
      html.link({href: path[i].href()}, path[i].getTitle());
      res.write(delimiter);
   }
   var title = path[i].getTitle();
   if (req.action !== "main" && path[i].main_action) {
      html.link({href: path[i].href()}, title);
   } else {
      res.write(title);
   }
   return;
}

function story_macro(param, id, mode) {
   var story = HopObject.getFromPath(id, "stories");
   if (!story || !story.getPermission("main")) {
      return;
   }

   switch (mode) {
      case "url":
      res.write(story.href());
      break;
      case "link":
      html.link({href: story.href()}, story.getTitle());
      break;
      default:
      story.renderSkin("Story#" + (param.skin || "main"));
   }
   return;
}

function file_macro(param, id, mode) {
   if (!id) {
      return;
   }

   var file;
   if (id.startsWith("/")) {
      name = id.substring(1);
      if (mode === "url") {
         res.write(root.getStaticUrl(name));
      } else {
         file = root.getStaticFile(name);
         res.push();
         File.prototype.contentLength_macro.call({
            contentLength: file.getLength()
         });
         res.handlers.file = {
            href: root.getStaticUrl(name),
            name: name,
            contentLength: res.pop()
         };
         File.prototype.renderSkin("File#main");
      }
      return;
   }

   file = HopObject.getFromPath(id, "files");
   if (!file) {
      return;
   }
   if (mode === "url") {
      res.write(file.getUrl());
   } else {
      file.renderSkin(param.skin || "File#main");
   }
   return;
}

function image_macro(param, id, mode) {
   if (!id) {
      return;
   }

   var image;
   if (id.startsWith("/")) {
      var name = id.substring(1);
      image = Images.Default[name] || Images.Default[name + ".gif"];
   } else {
      image = HopObject.getFromPath(id, "images");
      if (!image && param.fallback) {
         image = HopObject.getFromPath(param.fallback, "images");
      }
   }

   if (!image) {
      return;
   }
   
   switch (mode) {
      case "url":
      res.write(image.getUrl());
      break;
      case "thumbnail":
      case "popup":
      var url = image.getUrl();
      html.openTag("a", {href: url});
      // FIXME: Bloody popups belong to compatibility layer
      (mode === "popup") && (param.onclick = 'javascript:openPopup(\'' + 
            url + '\', ' + image.width + ', ' + image.height + '); return false;')
      image.thumbnail_macro(param);
      html.closeTag("a");
      break;
      default:
      image.render_macro(param);
   }
   return;
}

function poll_macro(param, id, mode) {
   if (!id) {
      return;
   }

   var poll = HopObject.getFromPath(id, "polls");
   if (!poll) {
      return;
   }

   switch (mode) {
      case "url":
      res.write(poll.href());
      break;
      case "link":
      html.link({
         href: poll.href(poll.closed ? "results" : "")
      }, poll.question);
      break;
      default:
      if (poll.status === Poll.CLOSED || mode === "results")
         poll.renderSkin("$Poll#results");
      else {
         res.data.action = poll.href();
         poll.renderSkin("$Poll#main");
      }
   }
   return;
}

function list_macro(param, id, limit) {
   if (!id) {
      return;
   }

   limit || (limit = 25);
   var max = Math.min(limit, 20);
   var collection, skin;
   if (id === "sites") {
      collection = root.sites.list(0, max);
      skin = "$Site#preview";
   } else if (id === "updates") {
      collection = root.updates.list(0, limit);
      skin = "$Site#preview";
      /*
      var site;
      collection = [];
      for (var i=0; i<root.size(); i+=1) {
         site = root.get(i);
         res.debug(site)
         if (this.mode === Site.OPEN || this.mode === Site.PUBLIC &&
               this.status !== Site.BLOCKED) {
            collection.push(site);
        }
         if (collection.length >= limit) {
            break;
         }
      }
      */
   } else {
      var site;
      var parts = id.split("/");
      if (parts.length > 1) {
         type = parts[1];
         site = root.sites.get(parts[0]);
      } else {
         type = parts[0];
      }

      site || (site = res.handlers.site);
      var filter = function(item, index) {
         return index < max && item.getPermission("main");
      }
      
      var commentFilter = function(item) {
         if (item.story.status !== Story.CLOSED && 
               item.site.commentMode !== Site.DISABLED &&
               item.story.commentMode !== Story.CLOSED) {
            return true;
         }
         return false;
      }

      switch (type) {
         case "comments":
         var comments = site.stories.comments;
         collection = comments.list().filter(filter).filter(commentFilter);
         skin = "$Comment#preview";
         break;
         
         case "featured":
         collection = site.stories.featured.list(0, max);
         skin = "$Story#preview";
         break;
         
         case "images":
         collection = site.images.list(0, max);
         skin = "$Image#preview";
         break;
         
         case "postings":
         content = site.stories.union;
         collection = content.list().filter(filter).filter(function(item) {
            if (item.constructor === Comment) {
               return commentFilter(item);
            }
            return true;
         });
         skin = "$Story#preview";
         break;
         
         case "stories":
         var stories = site.stories.recent;
         var counter = 0;
         collection = stories.list().filter(function(item, index) {
            return item.constructor === Story && filter(item, counter++);
         });
         skin = "$Story#preview";
         break;
         
         case "tags":
         return site.tags.list_macro(param, "$Tag#item");
         break;
         
         default:
         break;
      }
   }
   param.skin && (skin = param.skin);
   for each (var item in collection) {
      item.renderSkin(skin);
   }
   return;
}

function value_macro(param, name, value) {
   if (!name) {
      return;
   }
   name = name.toLowerCase();
   if (!value) {
      res.write(res.meta.values[name]);
   } else {
      //res.write("/* set " + name + " to " + value + " */");
      res.meta.values[name] = value;
   }
   return;
}

function randomize_macro(param, id) {
   var getRandom = function(n) {
      return Math.floor(Math.random() * n);
   };

   var site;
   if (id === "sites") {
      site = root.sites.get(getRandom(root.sites.size()));
      site.renderSkin(param.skin || "Site#preview");
      return;
   }

   var parts = id.split("/");
   if (parts.length > 1) {
      type = parts[1];
      site = root.sites.get(parts[0]);
   } else {
      type = parts[0];
   }
   site || (site = res.handlers.site);
   switch (type) {
      case "stories":
      var stories = site.stories["public"];
      var story = stories.get(getRandom(stories.size()));
      story && story.renderSkin(param.skin || "Story#preview");
      break;
      case "images":
      var image = site.images.get(getRandom(site.images.size()));
      image && image.renderSkin(param.skin || "Image#preview");
      break;
   }
   return;
}

function validateEmail(str) {
   if (str.isEmail()) {
      return str;
   }
   return null;
}

function validateUrl(str) {
   if (str) {
      if (url = helma.Http.evalUrl(str)) {
         return String(url);
      } else if (str.contains("@")) {
         return "mailto:" + str;
      } else {
         return "http://" + str;
      }
   }
   return null;
}

function quote(str) {
   if (/\W\D/.test(str)) {
      str = '"' + str + '"';
   }
   return str;
}

function formatNumber(number, pattern) {
   return number.format(pattern);
}

function formatDate(date, pattern) {
   if (!date) {
      return null;
   }
   pattern || (pattern = "short");
   var site = res.handlers.site;
   var format = global[pattern.toUpperCase() + "DATEFORMAT"];
   if (!format) {
      format = pattern;
   }
   try {
      return date.format(format, site.getLocale(), site.getTimeZone());
   } catch (ex) {
      app.log(ex);
      return "[Macro error: Invalid date format]";
   }
   return;
}

/**
 * Injects the XSLT stylesheet until Mozilla developers will have mercy.
 * @param {String} xml The original XML code
 * @returns The XML code with the appropriate element for the XSLT stylesheet
 * @type String
 */
function injectXslDeclaration(xml) {
   res.push();
   renderSkin("Global#xslDeclaration");
   return xml.replace(/(\?>\r?\n?)/, "$1" + res.pop());
}

/**
 * General mail sending function. Mails will be queued in app.data.mails.
 * @param {String} sender The sender's e-mail address
 * @param {Object} recipient The recipient's email addresses
 * @param {String} subject The e-mail's subject
 * @param {String} body The body text of the e-mail
 * @return The status code of the underlying helma.Mail instance.
 * @type Number
 */
function sendMail(sender, recipient, subject, body) {
   if (!recipient || !body) {
      throw Error("Insufficient arguments in method sendMail()");
   }
   var mail = new helma.Mail();
   mail.setFrom("noreply@antville.org");
   if (recipient instanceof Array) {
      for (var i in recipient) {
         mail.addBCC(recipient[i]);
      }
   } else {
      mail.addTo(recipient);
   }
   mail.setSubject(subject);
   mail.setText(body);
   mail.addText(renderSkinAsString("$Global#disclaimer"));
   mail.queue();
   return mail.status;
}

function getLocale(language) {
   return java.util.Locale[(language || "english").toUpperCase()];
}

/**
 * Creates an array of all available Java locales sorted by their names.
 * @param {String} language The optional language of the locales
 * @returns The sorted array containing the locales
 * @type Array
 * @member Global 
 */
function getLocales(language) {
   var result = [], locale;
   var displayLocale = getLocale(language);
   var locales = java.util.Locale.getAvailableLocales();
   for (var i in locales) {
      locale = locales[i].toString();
      result.push({
         value: locale,
         display: locales[i].getDisplayName(displayLocale),
      });
   }
   result.sort(new String.Sorter("display"));
   return result;
}

function getTimeZones(language) {
   var result = [], timeZone, offset;
   var locale = getLocale(language); 
   var zones = java.util.TimeZone.getAvailableIDs();
   for each (var zone in zones) {
      timeZone = java.util.TimeZone.getTimeZone(zone);
      offset = timeZone.getRawOffset();
      /*res.debug("zone id: " +  zone)
      res.debug("offset: " + timeZone.getRawOffset())
      res.debug("dst savings: " + timeZone.getDSTSavings())
      res.debug("old code: " + formatter.format(offset / Date.ONEHOUR))
      res.debug("new code: " + (offset / Date.ONEHOUR).format("+00;-00") + ":" + 
            (Math.abs(offset % Date.ONEHOUR) / Date.ONEMINUTE).format("00"))*/
      result.push({
         value: zone,
         display: zone + " (UTC" + (offset / Date.ONEHOUR).format("+00;-00") + 
               ":" + (Math.abs(offset % Date.ONEHOUR) / 
               Date.ONEMINUTE).format("00") + ")"
      });
   }
   result.sort(new String.Sorter("display"));
   return result;   
}

function getDateFormats(type, language) {
   var patterns;
   if (type === "short") {
      patterns = [SHORTDATEFORMAT, "yyyy/MM/dd HH:mm", 
            "yyyy.MM.dd, HH:mm", "d. MMMM, HH:mm", "MMMM d, HH:mm", 
            "d. MMM, HH:mm", "MMM d, HH:mm", "EEE, d. MMM, HH:mm", 
            "EEE MMM d, HH:mm", "EEE, HH:mm", "EE, HH:mm", "HH:mm"];
   } else if (type === "long") {
      patterns = [LONGDATEFORMAT, "EEEE, MMMM dd, yyyy, HH:mm", 
            "EE, d. MMM. yyyy, HH:mm", "EE MMM dd, yyyy, HH:mm", 
            "EE yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm", "d. MMMM yyyy, HH:mm", 
            "MMMM d, yyyy, HH:mm", "d. MMM yyyy, HH:mm", "MMM d, yyyy, HH:mm"];
   }
   var result = [], sdf;
   var locale = getLocale(language);
   var now = new Date;
   for each (var pattern in patterns) {
      sdf = new java.text.SimpleDateFormat(pattern, locale);
      result.push([encodeForm(pattern), sdf.format(now)]);
   }
   return result;
}

function age_filter(value, param) {
   if (!value || value.constructor !== Date) {
      return value;
   }
   return value.getAge()
}

function link_filter(value, param, url) {
   if (value) { 
      url || (url = value);
      return renderLink(param, url, value);
   }
   return;
}

function format_filter(value, param, pattern) {
   if (!value && value !== 0) {
      return;
   }
   var f = global["format" + value.constructor.name];
   if (f && f.constructor === Function) {
      return f(value, pattern || param.pattern);
   }
   return value;
}

function clip_filter(input, param, limit, clipping, delimiter) {
   var len = 0;
   if (input) {
      len = input.length;
      input = input.stripTags();
   }
   input || (input = ngettext("({0} character)", "({0} characters)", len));
   limit || (limit = 20);
   clipping || (clipping = "...");
   delimiter || (delimiter = "\\s");
   return String(input || "").head(limit, clipping, delimiter);
}

// FIXME:
function fixRssText(str) {
   var re = new RegExp("<img src\\s*=\\s*\"?([^\\s\"]+)?\"?[^>]*?(alt\\s*=\\s*\"?([^\"]+)?\"?[^>]*?)?>", "gi");
   str = str.replace(re, "[<a href=\"$1\" title=\"$3\">Image</a>]");
   return str;
}

// FIXME:
function countUsers() {
   app.data.activeUsers = new Array();
   var l = app.getActiveUsers();
   for (var i in l)
      app.data.activeUsers[app.data.activeUsers.length] = l[i];
   l = app.getSessions();
   app.data.sessions = 0;
   for (var i in l) {
      if (!l[i].user)
         app.data.sessions++;
   }
   app.data.activeUsers.sort();
   return;
}

// FIXME:
function pingUpdatedSites() {
   var c = getDBConnection("antville");
   var dbError = c.getLastError();
   if (dbError) {
      app.log("Error establishing DB connection: " + dbError);
      return;
   }

   var query = "select name from site where mode = 'online' and " +
         "SITE_ENABLEPING = 1 and  (SITE_LASTUPDATE > SITE_LASTPING or SITE_LASTPING is null)";
   var rows = c.executeRetrieval(query);
   var dbError = c.getLastError();
   if (dbError) {
      app.log("Error executing SQL query: " + dbError);
      return;
   }

   while (rows.next()) {
      var site = root.get(rows.getColumnItem("name"));
      app.log("Notifying weblogs.com for updated site '" + site.alias + 
            "' (id " + site._id + ")");
      site.ping();
   }

   rows.release();
   return;
}

// FIXME:
function extractContent(param, origContent) {
   var result = {isMajorUpdate: false, exists: false, value: new HopObject()};
   for (var i in param) {
      if (i.startsWith("content_")) {
         var partName = i.substring(8);
         var newContentPart = param[i].trim();
         // check if there's a difference between old and
         // new text of more than 50 characters:
         if (!result.isMajorUpdate && origContent) {
            var len1 = origContent[partName] ? origContent[partName].length : 0;
            var len2 = newContentPart.length;
            result.isMajorUpdate = Math.abs(len1 - len2) >= 50;
         }
         result.value[partName] = newContentPart;
         if (newContentPart)
            result.exists = true;
      }
   }
   return result;
}

// FIXME:
function doWikiStuff (src) {
   // robert, disabled: didn't get the reason for this:
   // var src= " "+src;
   if (src == null || !src.contains("<*"))
      return src;

   // do the Wiki link thing, <*asterisk style*>
   var regex = new RegExp ("<[*]([^*]+)[*]>");
   regex.ignoreCase=true;
   
   var text = "";
   var start = 0;
   while (true) {
      var found = regex.exec (src.substring(start));
      var to = found == null ? src.length : start + found.index;
      text += src.substring(start, to);
      if (found == null)
         break;
      var name = ""+(new java.lang.String (found[1])).trim();
      var item = res.handlers.site.topics.get (name);
      if (item == null && name.lastIndexOf("s") == name.length-1)
         item = res.handlers.site.topics.get (name.substring(0, name.length-1));
      if (item == null || !item.size())
         text += format(name)+" <small>[<a href=\""+res.handlers.site.stories.href("create")+"?topic="+escape(name)+"\">define "+format(name)+"</a>]</small>";
      else
         text += "<a href=\""+item.href()+"\">"+name+"</a>";
      start += found.index + found[1].length+4;
   }
   return text;
}

// FIXME: Rewrite with jala.ListRenderer?
function renderList(collection, funcOrSkin, itemsPerPage, pageIdx) {
   var currIdx = 0, item;
   var isArray = collection instanceof Array;
   var stop = size = isArray ? collection.length : collection.size();

   if (itemsPerPage) {
      var totalPages = Math.ceil(size/itemsPerPage);
      if (isNaN(pageIdx) || pageIdx > totalPages || pageIdx < 0) {
         pageIdx = 0;
      }
      currIdx = pageIdx * itemsPerPage;
      stop = Math.min(currIdx + itemsPerPage, size);
   }

   var isFunction = (funcOrSkin instanceof Function) ? true : false;
   res.push();
   while (currIdx < stop) {
      item = isArray ? collection[currIdx] : collection.get(currIdx);
      isFunction ? funcOrSkin(item) : item.renderSkin(funcOrSkin);
      currIdx += 1;
   }
   return res.pop();
}

// FIXME: Rewrite using jala.ListRenderer?
function renderPager(collectionOrSize, url, itemsPerPage, pageIdx) {
   // Render a single item for the navigation bar
   var renderItem = function(text, cssClass, url, page) {
      var param = {"class": cssClass};
      if (!url) {
         param.text = text;
      } else {
         if (url.contains("?"))
            param.text = html.linkAsString({href: url + "&page=" + page}, text);
         else
            param.text = html.linkAsString({href: url + "?page=" + page}, text);
      }
      renderSkin("$Global#pagerItem", param);
      return;
   }

   var maxItems = 10;
   var size = 0;
   if (collectionOrSize instanceof Array) {
      size = collectionOrSize.length;
   } else if (collectionOrSize instanceof HopObject) {
      size = collectionOrSize.size();
   } else if (!isNaN(collectionOrSize)) {
      size = parseInt(collectionOrSize, 10);
   }
   var lastPageIdx = Math.ceil(size/itemsPerPage)-1;
   // If there's just one page no navigation will be rendered
   if (lastPageIdx <= 0) {
      return null;
   }

   // Initialize the parameter object
   var param = {};
   var pageIdx = parseInt(pageIdx, 10);
   // Check if the passed index is correct
   if (isNaN(pageIdx) || pageIdx > lastPageIdx || pageIdx < 0) {
      pageIdx = 0;
   }
   param.display = ((pageIdx * itemsPerPage) + 1) + "-" + 
         (Math.min((pageIdx * itemsPerPage) + itemsPerPage, size));
   param.total = size;

   // Render the navigation-bar
   res.push();
   (pageIdx > 0) && renderItem("[–]", "pageNavItem", url, pageIdx-1);
   var offset = Math.floor(pageIdx / maxItems) * maxItems;
   (offset > 0) && renderItem("[..]", "pageNavItem", url, offset-1);
   var currPage = offset;
   var stop = Math.min(currPage + maxItems, lastPageIdx+1);
   while (currPage < stop) {
      if (currPage === pageIdx) {
         renderItem("[" + (currPage +1) + "]", "pageNavSelItem");
      } else {
         renderItem("[" + (currPage +1) + "]", "pageNavItem", url, currPage);
      }
      currPage += 1;
   }
   if (currPage < lastPageIdx) {
      renderItem("[..]", "pageNavItem", url, offset + maxItems);
   }
   if (pageIdx < lastPageIdx) {
      renderItem("[+]", "pageNavItem", url, pageIdx +1);
   }
   param.pager = res.pop();
   return renderSkinAsString("$Global#pager", param);
}

function singularize(plural) {
   if (plural.endsWith("ies")) {
      return plural.substring(0, plural.length-3) + "y";
   }
   return plural.substring(0, plural.length-1);
}

function pluralize(singular) {
   if (singular.endsWith("y")) {
      return singular.substring(0, singular.length-1) + "ies";
   }
   return singular + "s";
}
