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