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