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