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