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