      1 // The Antville Project
      2 // http://code.google.com/p/antville
      3 //
      4 // Copyright 2007-2011 by Tobi Schäfer.
      5 //
      6 // Copyright 2001–2007 Robert Gaggl, Hannes Wallnöfer, Tobi Schäfer,
      7 // Matthias & Michael Platzer, Christoph Lincke.
      8 //
      9 // Licensed under the Apache License, Version 2.0 (the ``License'');
     10 // you may not use this file except in compliance with the License.
     11 // You may obtain a copy of the License at
     12 //
     13 //    http://www.apache.org/licenses/LICENSE-2.0
     14 //
     15 // Unless required by applicable law or agreed to in writing, software
     16 // distributed under the License is distributed on an ``AS IS'' BASIS,
     17 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     18 // See the License for the specific language governing permissions and
     19 // limitations under the License.
     20 //
     21 // $Revision$
     22 // $Author$
     23 // $Date$
     24 // $URL$
     25
     26 /**
     27  * @fileOverview Defines the Story prototype.
     28  */
     29
     30 markgettext("Story");
     31 markgettext("story");
     32
     33 this.handleMetadata("title");
     34 this.handleMetadata("text");
     35
     36 /**
     37  * @function
     38  * @param {Object} data
     39  * @param {Site} site
     40  * @param {User} user
     41  * @returns {Story}
     42  */
     43 Story.add = function(data, site, user) {
     44    site || (site = res.handlers.site);
     45    user || (user = session.user);
     46    var story = new Story;
     47    story.site = site;
     48    story.update(data);
     49    story.creator = story.modifier = user;
     50    site.stories.add(story);
     51    return story;
     52 }
     53
     54 /**
     55  * @function
     56  */
     57 Story.remove = function() {
     58    if (this.constructor === Story) {
     59       HopObject.remove.call(this.comments);
     60       this.setTags(null);
     61       this.deleteMetadata();
     62       this.remove();
     63    }
     64    return;
     65 }
     66
     67 /**
     68  * @function
     69  * @returns {String[]}
     70  * @see defineConstants
     71  */
     72 Story.getStatus = defineConstants(Story, markgettext("closed"),
     73       markgettext("public"), markgettext("shared"), markgettext("open"));
     74 /**
     75  * @function
     76  * @returns {String[]}
     77  * @see defineConstants
     78  */
     79 Story.getModes = defineConstants(Story, markgettext("hidden"),
     80       markgettext("featured"));
     81 /**
     82  * @function
     83  * @returns {String[]}
     84  * @see defineConstants
     85  */
     86 Story.getCommentModes = defineConstants(Story, markgettext("closed"),
     87       /* markgettext("readonly"), markgettext("moderated"), */
     88       markgettext("open"));
     89
     90 /**
     91  * @name Story
     92  * @constructor
     93  * @property {Comment[]} _children
     94  * @property {String} commentMode
     95  * @property {Comment[]} comments
     96  * @property {Date} created
     97  * @property {User} creator
     98  * @property {Metadata} metadata
     99  * @property {String} mode
    100  * @property {Date} modified
    101  * @property {User} modifier
    102  * @property {String} name
    103  * @property {Number} parent_id
    104  * @property {String} parent_type
    105  * @property {String} prototype
    106  * @property {Number} requests
    107  * @property {Site} site
    108  * @property {String} status
    109  * @property {TagHub[]} tags
    110  * @property {String} text
    111  * @property {String} title
    112  * @extends HopObject
    113  */
    114 Story.prototype.constructor = function() {
    115    this.name = String.EMPTY;
    116    this.requests = 0;
    117    this.status = Story.PUBLIC;
    118    this.mode = Story.FEATURED;
    119    this.commentMode = Story.OPEN;
    120    this.created = this.modified = new Date;
    121    return this;
    122 }
    123
    124 /**
    125  *
    126  * @param {String} action
    127  * @returns {Boolean}
    128  */
    129 Story.prototype.getPermission = function(action) {
    130    if (!this.site.getPermission("main")) {
    131       return false;
    132    }
    133    switch (action) {
    134       case ".":
    135       case "main":
    136       return this.status !== Story.CLOSED ||
    137             this.creator === session.user ||
    138             Membership.require(Membership.MANAGER) ||
    139             User.require(User.PRIVILEGED);
    140       case "comment":
    141       return this.site.commentMode === Site.ENABLED &&
    142             (this.commentMode === Story.OPEN ||
    143             this.commentMode === Story.MODERATED);
    144       case "delete":
    145       return this.creator === session.user ||
    146             Membership.require(Membership.MANAGER) ||
    147             User.require(User.PRIVILEGED);
    148       case "edit":
    149       case "rotate":
    150       return this.creator === session.user ||
    151             Membership.require(Membership.MANAGER) ||
    152             (this.status === Story.SHARED &&
    153             Membership.require(Membership.CONTRIBUTOR)) ||
    154             (this.status === Story.OPEN &&
    155             Membership.require(Membership.SUBSCRIBER)) ||
    156             User.require(User.PRIVILEGED);
    157    }
    158    return false;
    159 }
    160
    161 Story.prototype.main_action = function() {
    162    res.data.title = this.getTitle(5);
    163    res.data.body = this.renderSkinAsString("Story#main");
    164    this.site.renderSkin("Site#page");
    165    this.site.log();
    166    this.count();
    167    this.log();
    168    return;
    169 }
    170
    171 /**
    172  *
    173  * @param {Number} limit
    174  * @returns {String}
    175  */
    176 Story.prototype.getTitle = function(limit) {
    177    var key = this + ":title:" + limit;
    178    if (!res.meta[key]) {
    179       if (this.title) {
    180          res.meta[key] = stripTags(this.title).clip(limit, "...", "\\s");
    181       } else if (this.text) {
    182          var parts = stripTags(this.text).embody(limit, "...", "\\s");
    183          res.meta[key] = parts.head;
    184          res.meta[this + ":text:" + limit] = parts.tail;
    185       }
    186    }
    187    return String(res.meta[key]) || "...";
    188 }
    189
    190 Story.prototype.edit_action = function() {
    191    if (req.postParams.save) {
    192       try {
    193          this.update(req.postParams);
    194          delete session.data.backup;
    195          res.message = gettext("The story was successfully updated.");
    196          res.redirect(this.href());
    197       } catch (ex) {
    198          res.message = ex;
    199          app.log(ex);
    200       }
    201    }
    202
    203    res.data.action = this.href(req.action);
    204    res.data.title = gettext('Edit Story');
    205    res.data.body = this.renderSkinAsString("Story#edit");
    206    this.site.renderSkin("Site#page");
    207    return;
    208 }
    209
    210 /**
    211  *
    212  * @param {Object} data
    213  */
    214 Story.prototype.update = function(data) {
    215    var site = this.site || res.handlers.site;
    216
    217    if (!data.title && !data.text) {
    218       throw Error(gettext("Please enter at least something into the “title” or “text” field."));
    219    }
    220    if (data.created) {
    221       try {
    222          this.created = data.created.toDate("yyyy-MM-dd HH:mm", site.getTimeZone());
    223       } catch (ex) {
    224          throw Error(gettext("Cannot parse timestamp {0} as a date.", data.created));
    225          app.log(ex);
    226       }
    227    }
    228
    229    // Get difference to current content before applying changes
    230    var delta = this.getDelta(data);
    231    this.title = data.title ? data.title.trim() : String.EMPTY;
    232    this.text = data.text ? data.text.trim() : String.EMPTY;
    233    this.status = data.status || Story.PUBLIC;
    234    this.mode = data.mode || Story.FEATURED;
    235    this.commentMode = data.commentMode || Story.OPEN;
    236    this.setCustomContent(data);
    237
    238    // FIXME: To be removed resp. moved to Stories.create_action and
    239    // Story.edit_action if work-around for Helma bug #607 fails
    240    // We need persistence for setting the tags
    241    this.isTransient() && this.persist();
    242    this.setTags(data.tags || data.tag_array);
    243
    244    if (delta > 50) {
    245       this.modified = new Date;
    246       if (this.status !== Story.CLOSED) {
    247          site.modified = this.modified;
    248       }
    249       site.callback(this);
    250       // Notification is sent in Stories.create_action()
    251    }
    252
    253    this.clearCache();
    254    this.modifier = session.user;
    255    return;
    256 }
    257
    258 Story.prototype.rotate_action = function() {
    259    if (this.status === Story.CLOSED) {
    260       this.status = this.cache.status || Story.PUBLIC;
    261    } else if (this.mode === Story.FEATURED) {
    262       this.mode = Story.HIDDEN;
    263    } else {
    264       this.cache.status = this.status;
    265       this.mode = Story.FEATURED;
    266       this.status = Story.CLOSED;
    267    }
    268    return res.redirect(req.data.http_referer || this._parent.href());
    269 }
    270
    271 Story.prototype.comment_action = function() {
    272    // Check if user is logged in since we allow linking here for any user
    273    if (!User.require(User.REGULAR)) {
    274       User.setLocation(this.href(req.action) + "#form");
    275       res.message = gettext("Please login first.");
    276       res.redirect(this.site.members.href("login"));
    277    }
    278    var comment = new Comment(this);
    279    if (req.postParams.save) {
    280       try {
    281          comment.update(req.postParams);
    282          this.add(comment);
    283          // Force addition to aggressively cached collection
    284          (this.story || this).comments.add(comment);
    285          comment.notify(req.action);
    286          delete session.data.backup;
    287          res.message = gettext("The comment was successfully created.");
    288          res.redirect(comment.href());
    289       } catch (ex) {
    290          res.message = ex;
    291          app.log(ex);
    292       }
    293    }
    294    res.handlers.parent = this;
    295    res.data.action = this.href(req.action);
    296    res.data.title = gettext("Add Comment");
    297    res.data.body = comment.renderSkinAsString("Comment#edit");
    298    this.site.renderSkin("Site#page");
    299    return;
    300 }
    301
    302 /**
    303  *
    304  * @param {String} name
    305  * @returns {Object}
    306  */
    307 Story.prototype.getFormValue = function(name) {
    308    if (req.isPost()) {
    309       return req.postParams[name];
    310    }
    311    switch (name) {
    312       case "commentMode":
    313       return this.commentMode || Story.OPEN;
    314       case "mode":
    315       return this.mode || Story.FEATURED;
    316       case "status":
    317       return this.status || Story.PUBLIC;
    318       case "tags":
    319       return this.getTags().join(Tag.DELIMITER);
    320    }
    321    return this[name] || this.getMetadata(name);
    322 }
    323
    324 /**
    325  *
    326  * @param {String} name
    327  * @returns {String[]}
    328  */
    329 Story.prototype.getFormOptions = function(name) {
    330    switch (name) {
    331       case "commentMode":
    332       return Story.getCommentModes();
    333       case "mode":
    334       return Story.getModes();
    335       case "status":
    336       return Story.getStatus();
    337       case "tags":
    338       // FIXME: This could become a huge select element...
    339       return [];
    340    }
    341    return;
    342 }
    343
    344 /**
    345  *
    346  * @param {Object} data
    347  */
    348 Story.prototype.setCustomContent = function(data) {
    349    var metadata = {};
    350    for (var key in data) {
    351       if (this.isCustomContent(key)) {
    352          metadata[key] = data[key];
    353       }
    354    }
    355    return HopObject.prototype.setMetadata.call(this, metadata);
    356 }
    357
    358 /**
    359  *
    360  * @param {String} name
    361  */
    362 Story.prototype.isCustomContent = function(key) {
    363    return this[key] === undefined && key !== 'save';
    364 }
    365
    366 /**
    367  * Increment the request counter
    368  */
    369 Story.prototype.count = function() {
    370    if (session.user === this.creator) {
    371       return;
    372    }
    373    var story;
    374    var key = "Story#" + this._id;
    375    if (story = app.data.requests[key]) {
    376       story.requests += 1;
    377    } else {
    378       app.data.requests[key] = {
    379          type: this.constructor,
    380          id: this._id,
    381          requests: this.requests + 1
    382       };
    383    }
    384    return;
    385 }
    386
    387 /**
    388  * Calculate the difference of a story’s current and its updated content
    389  * @param {Object} data
    390  * @returns {Number}
    391  */
    392 Story.prototype.getDelta = function(data) {
    393    if (this.isTransient()) {
    394       return Infinity;
    395    }
    396
    397    var deltify = function(s1, s2) {
    398       var len1 = s1 ? String(s1).length : 0;
    399       var len2 = s2 ? String(s2).length : 0;
    400       return Math.abs(len1 - len2);
    401    };
    402
    403    var delta = 0;
    404    delta += deltify(data.title, this.title);
    405    delta += deltify(data.text, this.text);
    406    for (var key in data) {
    407       if (this.isCustomContent(key)) {
    408          delta += deltify(data[key], this.getMetadata(key))
    409       }
    410    }
    411    // In-between updates (1 hour) get zero delta
    412    var timex = (new Date - this.modified) > Date.ONEHOUR ? 1 : 0;
    413    return delta * timex;
    414 }
    415
    416 /**
    417  *
    418  * @param {String} name
    419  * @returns {HopObject}
    420  */
    421 Story.prototype.getMacroHandler = function(name) {
    422    if (name === "metadata") {
    423       return this.getMetadata();
    424    }
    425    return null;
    426 }
    427
    428 /**
    429  *
    430  * @param {Object} param
    431  * @param {String} action
    432  * @param {String} text
    433  */
    434 Story.prototype.link_macro = function(param, action, text) {
    435    switch (action) {
    436       case "rotate":
    437       if (this.status === Story.CLOSED) {
    438          text = gettext("Publish");
    439       } else if (this.mode === Story.FEATURED) {
    440          text = gettext("Hide");
    441       } else {
    442          text = gettext("Close");
    443       }
    444    }
    445    return HopObject.prototype.link_macro.call(this, param, action, text);
    446 }
    447
    448 /**
    449  *
    450  * @param {Object} param
    451  */
    452 Story.prototype.summary_macro = function(param) {
    453    param.limit || (param.limit = 15);
    454    var keys, summary;
    455    if (arguments.length > 1) {
    456       res.push();
    457       var content;
    458       for (var i=1; i<arguments.length; i+=1) {
    459          if (content = this.getMetadata(arguments[i])) {
    460             res.write(content);
    461             res.write(String.SPACE);
    462          }
    463       }
    464       summary = res.pop();
    465    }
    466    if (!summary) {
    467       summary = (this.title || String.EMPTY) + String.SPACE +
    468             (this.text || String.EMPTY);
    469    }
    470    var clipped = stripTags(summary).clip(param.limit, param.clipping, "\\s");
    471    var head = clipped.split(/(\s)/, param.limit * 0.6).join(String.EMPTY);
    472    var tail = clipped.substring(head.length).trim();
    473    head = head.trim();
    474    if (!head && !tail) {
    475       head = "...";
    476    }
    477    html.link({href: this.href()}, head);
    478    res.writeln("\n");
    479    res.write(tail);
    480    return;
    481 }
    482
    483 /**
    484  *
    485  * @param {Object} param
    486  * @param {String} mode
    487  */
    488 Story.prototype.comments_macro = function(param, mode) {
    489    var story = this.story || this;
    490    if (story.site.commentMode === Site.DISABLED ||
    491          story.commentMode === Site.CLOSED) {
    492       return;
    493    } else if (mode) {
    494       var n = this.comments.size() || 0;
    495       var text = ngettext("{0} comment", "{0} comments", n);
    496       if (mode === "count" || mode === "size") {
    497          res.write(text);
    498       } else if (mode === "link") {
    499          n < 1 ? res.write(text) :
    500                html.link({href: this.href() + "#comments"}, text);
    501       }
    502    } else {
    503       this.prefetchChildren();
    504       this.forEach(function() {
    505          html.openTag("a", {name: this._id});
    506          html.closeTag("a");
    507          this.renderSkin(this.parent.constructor === Story ?
    508                "Comment#main" : "Comment#reply");
    509       });
    510    }
    511    return;
    512 }
    513
    514 /**
    515  *
    516  * @param {Object} param
    517  * @param {String} mode
    518  */
    519 Story.prototype.tags_macro = function(param, mode) {
    520    if (mode === "link") {
    521       var tags = [];
    522       this.tags.list().forEach(function(item) {
    523          res.push();
    524          if (item.tag) {
    525             renderLink(param, item.tag.href(), item.tag.name);
    526             tags.push(res.pop());
    527          }
    528       });
    529       return res.write(tags.join(Tag.DELIMITER));
    530    }
    531    return res.write(this.getFormValue("tags"));
    532 }
    533
    534 /**
    535  *
    536  * @param {Object} param
    537  * @param {Number} limit
    538  */
    539 Story.prototype.referrers_macro = function(param, limit) {
    540    if (!User.require(User.PRIVILEGED) &&
    541          !Membership.require(Membership.OWNER)) {
    542       return;
    543    }
    544
    545    limit = Math.min(limit || param.limit || 100, 100);
    546    if (limit < 1) {
    547       return;
    548    }
    549
    550    var self = this;
    551    var sql = new Sql;
    552    sql.retrieve(Sql.REFERRERS, "Story", this._id);
    553
    554    res.push();
    555    var n = 0;
    556    sql.traverse(function() {
    557       if (n < limit && this.requests && this.referrer) {
    558          this.text = encode(this.referrer.head(50));
    559          this.referrer = encode(this.referrer);
    560          self.renderSkin("$Story#referrer", this);
    561       }
    562       n += 1;
    563    });
    564    param.referrers = res.pop();
    565    if (param.referrers) {
    566       this.renderSkin("$Story#referrers", param);
    567    }
    568    return;
    569 }
    570
    571 /**
    572  *
    573  * @param {Object} value
    574  * @param {Object} param
    575  * @param {String} mode
    576  * @returns {String}
    577  */
    578 Story.prototype.format_filter = function(value, param, mode) {
    579    if (value) {
    580       switch (mode) {
    581          case "plain":
    582          return this.url_filter(stripTags(value), param, mode);
    583
    584          case "quotes":
    585          return stripTags(value).replace(/(?:\x22|\x27)/g, function(quote) {
    586             return "&#" + quote.charCodeAt(0) + ";";
    587          });
    588
    589          case "image":
    590          var image = HopObject.getFromPath(value, "images");
    591          if (image) {
    592             res.push();
    593             image.render_macro(param);
    594             return res.pop();
    595          }
    596          break;
    597
    598          default:
    599          value = this.macro_filter(format(value), param);
    600          return this.url_filter(value, param);
    601       }
    602    }
    603    return String.EMTPY;
    604 }
    605
    606 /**
    607  *
    608  * @param {String|Skin} value
    609  * @param {Object} param
    610  * @returns {String}
    611  */
    612 Story.prototype.macro_filter = function(value, param) {
    613    var skin = value.constructor === String ? createSkin(value) : value;
    614    skin.allowMacro("image");
    615    skin.allowMacro("this.image");
    616    skin.allowMacro("site.image");
    617    skin.allowMacro("story.image");
    618    skin.allowMacro("thumbnail");
    619    skin.allowMacro("this.thumbnail");
    620    skin.allowMacro("site.thumbnail");
    621    skin.allowMacro("story.thumbnail");
    622    skin.allowMacro("link");
    623    skin.allowMacro("this.link");
    624    skin.allowMacro("site.link");
    625    skin.allowMacro("story.link");
    626    skin.allowMacro("file");
    627    skin.allowMacro("poll");
    628    skin.allowMacro("logo");
    629    skin.allowMacro("storylist");
    630    skin.allowMacro("fakemail");
    631    skin.allowMacro("this.topic");
    632    skin.allowMacro("story.topic");
    633    skin.allowMacro("imageoftheday");
    634    skin.allowMacro("spacer");
    635
    636    var site;
    637    if (this.site !== res.handlers.site) {
    638       site = res.handlers.site;
    639       res.handlers.site = this.site;
    640    }
    641    value = this.renderSkinAsString(skin);
    642    site && (res.handlers.site = site);
    643    return value;
    644 }
    645
    646 /**
    647  *
    648  * @param {String} value
    649  * @param {Object} param
    650  * @param {String} mode
    651  * @returns {String}
    652  */
    653 Story.prototype.url_filter = function(value, param, mode) {
    654    param.limit || (param.limit = 50);
    655    // FIXME: The first RegExp has troubles with <a href=http://... (no quotes)
    656    //var re = /(^|\/>|\s+)([\w+-_]+:\/\/[^\s]+?)([\.,;:\)\]\"]?)(?=[\s<]|$)/gim;
    657    var re = /(^|\/>|\s+)([!fhtpsr]+:\/\/[^\s]+?)([\.,;:\)\]\"]?)(?=[\s<]|$)/gim
    658    return value.replace(re, function(str, head, url, tail) {
    659       if (url.startsWith("!")) {
    660          return head + url.substring(1) + tail;
    661       }
    662       res.push();
    663       res.write(head);
    664       if (mode === "plain") {
    665          res.write(url.clip(param.limit));
    666       } else {
    667          var text, location = /:\/\/([^\/]*)/.exec(url)[1];
    668          text = location;
    669          if (mode === "extended") {
    670             text = url.replace(/^.+\/([^\/]*)$/, "$1");
    671          }
    672          html.link({href: url, title: url}, text.clip(param.limit));
    673          if (mode === "extended" && text !== location) {
    674             res.write(" <small>(" + location + ")</small>");
    675          }
    676       }
    677       res.write(tail);
    678       return res.pop();
    679    });
    680 }
    681
    682 /**
    683  * @returns {String}
    684  */
    685 Story.prototype.getConfirmText = function() {
    686    return gettext("You are about to delete a story by user {0}.",
    687          this.creator.name);
    688 }
    689